diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9ecd75d86..72b5c7264 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -25,7 +25,7 @@ amethyst/ **Sharing Philosophy:** - `quartz/` = Business logic, protocol, data (no UI) -- `commons/` = Shared UI components, icons, composables +- `commons/` = Shared UI components, icons, composables, **ViewModels** - `amethyst/` & `desktopApp/` = Platform-native layouts and navigation ## Tech Stack @@ -40,33 +40,115 @@ amethyst/ | **DI** | Manual / Koin | | **Build** | Gradle 8.x, Kotlin 2.1.0 | -## Agents +## Skills -Use these specialized agents for domain expertise: +Specialized skills provide domain expertise with bundled resources and patterns: -| Agent | Expertise | When to Use | +| Skill | Expertise | When to Use | |-------|-----------|-------------| -| `nostr-protocol` | NIPs, events, relays, crypto | Protocol questions, NIP implementation | -| `kotlin-multiplatform` | KMP, source sets, expect/actual | Project structure, code sharing | -| `compose-ui` | Composables, Desktop features | UI components, navigation | -| `kotlin-coroutines` | Flows, async, concurrency | Data streams, async operations | +| `nostr-expert` | Nostr protocol (Quartz library) | Event types, NIPs, tags, signing, Bech32 | +| `kotlin-expert` | Advanced Kotlin patterns | StateFlow, sealed classes, @Immutable, DSLs | +| `kotlin-coroutines` | Advanced async patterns | supervisorScope, callbackFlow, relay pools, testing | +| `kotlin-multiplatform` | Platform abstraction | expect/actual, source sets, sharing decisions | +| `compose-expert` | Shared UI components | Material3, state hoisting, recomposition | +| `android-expert` | Android platform | Navigation, permissions, lifecycle, Material3 | +| `desktop-expert` | Desktop platform | Window, MenuBar, Tray, keyboard shortcuts | +| `gradle-expert` | Build system | Dependencies, versioning, packaging, optimization | + +## Workflow + +**When you ask for a feature:** + +1. **Quick skill assessment** - I identify which skills are relevant +2. **Propose which skills** - I present which skills I'll use for the task +3. **Get approval** - You review and approve (or adjust) the skill selection +4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan +5. **Execute with skills** - Skills collaborate to implement the feature + +**Example:** +``` +You: "Add video support to notes" +Me: "I'll use: + - /nostr-expert (NIP-71 video events) + - /compose-expert (video player UI) + - /android-expert (platform video APIs) + Proceed?" +You: "yes" +Me: [invokes skills to create plan] + "Plan from skills: + 1. nostr-expert: Use NIP-71 kind 34235 for video events... + 2. compose-expert: Create VideoPlayer composable in commons... + 3. android-expert: Use ExoPlayer for Android... + Proceed with implementation?" +You: "yes" +Me: [implements using skill guidance] +``` ## Commands - `/desktop-run` - Build and run desktop app -- `/extract ` - Move composable to shared code - `/nip ` - Get NIP implementation guidance ## Feature Workflow +**CRITICAL: Always check existing implementations first before creating new code!** + When picking up a new task or feature, follow this process: +### Step 0: Survey Existing Implementation (MANDATORY) + +**Before writing ANY code, thoroughly audit ALL modules:** + +1. **Search for existing implementations across all modules:** + ```bash + # Search in quartz for protocol/business logic + grep -r "class.*Manager\|object.*Cache\|class.*Filter" quartz/src/commonMain/ + + # Search in commons for UI components + grep -r "@Composable.*Card\|@Composable.*View\|@Composable.*Dialog" commons/src/ + + # Search in amethyst for Android patterns + grep -r "class.*ViewModel\|class.*Account\|class.*State" amethyst/src/main/java/ + + # Search for specific functionality + grep -r "fun isFollowing\|fun subscribe\|fun getMetadata" {quartz,commons,amethyst}/src/ + ``` + +2. **Understand existing architecture patterns:** + - Event stores and caching systems + - State management patterns (StateFlow, mutable states) + - ViewModel patterns and lifecycle handling + - Filter builders and relay subscription patterns + - UI component hierarchies + +3. **Key principle:** Most logic already exists! Your job is to: + - **Reuse** existing protocol/business logic from quartz + - **Extract** shareable UI components AND ViewModels from amethyst to commons + - Create **platform-specific** layouts/navigation for Desktop + - **NOT** duplicate existing managers, caches, or state systems + +4. **Document findings in implementation plan as a matrix:** + + | File/Component | Status | Location | Action | + |----------------|--------|----------|--------| + | FilterBuilders | ✅ Exists | quartz/relay/filters/ | Reuse as-is | + | NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons | + | HomeFeedViewModel | 📦 Extract | amethyst/ → commons/commonMain/viewmodels/ | Extract to commons | + | ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern | + + **Legend:** + - ✅ **Reuse** - Exists and can be used directly + - 📦 **Extract** - Exists in Android, needs extraction to commons + - 🆕 **New** - Doesn't exist, needs creation (platform-specific only) + - ⚠️ **Avoid** - Duplicate functionality, use existing pattern instead + ### Step 1: Analyze Android Implementation -Start by examining the existing Android Amethyst codebase: +After surveying (Step 0), deeply examine the Android implementation: 1. Find the relevant feature/component in `amethyst/` module 2. Understand the current implementation patterns 3. Identify dependencies and integrations +4. Map out what code can be shared vs platform-specific ### Step 2: Create Implementation Plan @@ -74,30 +156,32 @@ Before coding, create a plan that categorizes work into three buckets: | Category | Description | Location | |----------|-------------|----------| -| **Android-Specific** | Platform APIs, navigation, layouts | `amethyst/`, `androidMain/` | -| **Reusable (Shared)** | Business logic, UI components, state | `quartz/commonMain/`, `commons/` (convert to KMP) | -| **Desktop-Specific** | Desktop navigation, layouts, platform APIs | `desktopApp/`, `jvmMain/` | +| **Android-Specific** | Platform-native layouts, navigation patterns | `amethyst/`, `androidMain/` | +| **Reusable (Shared)** | Business logic, UI components, **ViewModels**, state management | `quartz/commonMain/`, `commons/commonMain/` | +| **Desktop-Specific** | Desktop-native layouts, navigation patterns, platform APIs | `desktopApp/`, `jvmMain/` | ### Step 3: Code Sharing Strategy **Share:** - Business logic and data models → `quartz/commonMain/` -- Major UI components (cards, lists, dialogs) → `commons/` (convert to KMP as needed) -- State management and ViewModels → shared +- Major UI components (cards, lists, dialogs) → `commons/commonMain/` +- **ViewModels** (state, business logic) → `commons/commonMain/viewmodels/` - Icons and visual assets → `commons/commonMain/` **Keep Platform-Native:** +- **Screen composables** (layout, scaffolding) - Desktop uses `Window`, Android uses `Activity` - Navigation patterns (sidebar vs bottom nav) -- Screen layouts and scaffolding - Platform-specific interactions (gestures, keyboard shortcuts) - System integrations (notifications, file pickers) +**Rationale:** ViewModels contain platform-agnostic state management (StateFlow/SharedFlow) and business logic. Screens consume ViewModels but render differently (Desktop sidebar + content area vs Android bottom nav). + ### Step 4: Extract Shared Components When extracting UI components: 1. Identify reusable composables in Android code -2. Use `/extract ` to move to `commons/commonMain/` -3. Create expect/actual declarations for platform-specific behavior +2. Move to `commons/commonMain/` (consult `/compose-expert` for patterns) +3. Create expect/actual declarations for platform-specific behavior (consult `/kotlin-multiplatform`) 4. Update both Android and Desktop to use shared component **Note:** `quartz/` is protocol-only (no composables). Shared UI goes in `commons/` after converting it to KMP. diff --git a/.claude/agents/compose-ui.md b/.claude/agents/compose-ui.md deleted file mode 100644 index d51b2eca8..000000000 --- a/.claude/agents/compose-ui.md +++ /dev/null @@ -1,169 +0,0 @@ ---- -name: compose-ui -description: Automatically invoked when working with Compose Multiplatform UI code, @Composable functions, desktop Window/MenuBar/Tray, navigation patterns, or UI components in desktopApp/ or shared UI modules. -tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch -model: sonnet ---- - -# Compose Multiplatform UI Agent - -You are a Compose Multiplatform UI expert specializing in shared composables and desktop-specific features. - -## Auto-Trigger Contexts - -Activate when user works with: -- `@Composable` functions -- `desktopApp/` module files -- `Window`, `MenuBar`, `Tray` components -- Navigation patterns (NavigationRail, screens) -- Material3 theming -- Keyboard shortcuts, context menus - -## Core Knowledge - -### Desktop Entry Point -```kotlin -fun main() = application { - Window( - onCloseRequest = ::exitApplication, - state = rememberWindowState(width = 1200.dp, height = 800.dp), - title = "Amethyst Desktop" - ) { - MenuBar { - Menu("File") { - Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { } - Item("Quit", onClick = ::exitApplication) - } - } - App() - } -} -``` - -### Desktop-Specific Features - -**Menu Bar** -```kotlin -MenuBar { - Menu("File") { - Item("New", shortcut = KeyShortcut(Key.N, ctrl = true)) { } - Separator() - Item("Quit", onClick = ::exitApplication) - } -} -``` - -**System Tray** -```kotlin -Tray( - icon = painterResource("icon.png"), - menu = { - Item("Show", onClick = { windowVisible = true }) - Item("Exit", onClick = ::exitApplication) - } -) -``` - -**Context Menus** -```kotlin -ContextMenuArea(items = { - listOf( - ContextMenuItem("Copy") { copyToClipboard(text) }, - ContextMenuItem("Reply") { openReply() } - ) -}) { - Text(content) -} -``` - -**Keyboard Shortcuts** -```kotlin -Modifier.onKeyEvent { event -> - when { - event.isCtrlPressed && event.key == Key.Enter -> { send(); true } - event.key == Key.Escape -> { close(); true } - else -> false - } -} -``` - -### Navigation Pattern -```kotlin -@Composable -fun DesktopLayout(currentScreen: Screen, onNavigate: (Screen) -> Unit) { - Row(Modifier.fillMaxSize()) { - NavigationRail { - NavigationRailItem( - icon = { Icon(Icons.Default.Home, "Feed") }, - selected = currentScreen == Screen.Feed, - onClick = { onNavigate(Screen.Feed) } - ) - // More items... - } - Box(Modifier.weight(1f)) { - when (currentScreen) { - Screen.Feed -> FeedScreen() - Screen.Messages -> MessagesScreen() - } - } - } -} -``` - -### Platform Differences - -| Aspect | Android | Desktop | -|--------|---------|---------| -| **Entry** | Activity | main() + Window | -| **Navigation** | Bottom nav | Sidebar / MenuBar | -| **Input** | Touch | Mouse + Keyboard | -| **Windows** | Single | Multi-window | -| **Menus** | Overflow | MenuBar | - -## Workflow - -### 1. Assess Task -- Shared composable or desktop-specific? -- Navigation change or component work? -- State management needs? - -### 2. Investigate -```bash -# Find existing composables -grep -r "@Composable" desktopApp/src/ -# Check navigation structure -grep -r "Screen\|navigate" desktopApp/src/ -``` - -### 3. Implement -- Shared composables go in shared UI module -- Desktop-specific (Window, MenuBar) in desktopApp -- Use Material3 components -- Handle keyboard/mouse input for desktop - -### 4. Verify -```bash -./gradlew :desktopApp:run -``` - -## State Management -```kotlin -class FeedViewModel { - private val _state = MutableStateFlow(FeedState()) - val state: StateFlow = _state.asStateFlow() -} - -@Composable -fun FeedScreen(viewModel: FeedViewModel) { - val state by viewModel.state.collectAsState() - // UI based on state -} -``` - -## Constraints - -- Prefer shared composables in commonMain when possible -- Desktop-specific features only in desktopApp -- Follow Material3 design guidelines -- Support keyboard navigation for accessibility -- Test on macOS, Windows, Linux when possible diff --git a/.claude/agents/kotlin-coroutines.md b/.claude/agents/kotlin-coroutines.md deleted file mode 100644 index ed1679972..000000000 --- a/.claude/agents/kotlin-coroutines.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -name: kotlin-coroutines -description: Automatically invoked when working with coroutines, Flow, StateFlow, SharedFlow, suspend functions, CoroutineScope, channels, or async patterns in Kotlin code. -tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch -model: sonnet ---- - -# Kotlin Coroutines Agent - -You are a Kotlin Coroutines expert specializing in async patterns, Flow, and structured concurrency. - -## Auto-Trigger Contexts - -Activate when user works with: -- `suspend` functions -- `Flow`, `StateFlow`, `SharedFlow` -- `CoroutineScope`, `launch`, `async` -- `Channel`, `channelFlow` -- Dispatchers configuration -- Exception handling in coroutines - -## Core Knowledge - -### Coroutine Builders -```kotlin -// launch: fire-and-forget -val job = launch { delay(1000); println("Done") } - -// async: returns result -val deferred = async { computeValue() } -val result = deferred.await() - -// Structured concurrency -suspend fun loadProfile(userId: String) = coroutineScope { - val user = async { fetchUser(userId) } - val notes = async { fetchNotes(userId) } - Profile(user.await(), notes.await()) -} -``` - -### Dispatchers - -| Dispatcher | Use Case | -|------------|----------| -| `Dispatchers.Main` | UI updates | -| `Dispatchers.IO` | Network, disk I/O | -| `Dispatchers.Default` | CPU-intensive | - -### Flow (Cold Streams) -```kotlin -fun observeNotes(): Flow> = flow { - while (true) { - emit(repository.getNotes()) - delay(30_000) - } -} - -// Operators -repository.observeNotes() - .map { it.filter { note -> note.isVisible } } - .distinctUntilChanged() - .catch { emit(emptyList()) } - .flowOn(Dispatchers.IO) - .collect { updateUI(it) } -``` - -### StateFlow (Hot, Always Has Value) -```kotlin -class FeedViewModel { - private val _state = MutableStateFlow(FeedState()) - val state: StateFlow = _state.asStateFlow() - - fun updateFilter(filter: Filter) { - _state.update { it.copy(filter = filter) } - } -} -``` - -### SharedFlow (Hot, Configurable Replay) -```kotlin -private val _events = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 64, - onBufferOverflow = BufferOverflow.DROP_OLDEST -) -val events: SharedFlow = _events.asSharedFlow() -``` - -### Channels -```kotlin -fun relayEvents(relay: Relay): Flow = channelFlow { - relay.connect() - relay.onEvent { event -> trySend(event) } - awaitClose { relay.disconnect() } -} -``` - -### Exception Handling -```kotlin -val handler = CoroutineExceptionHandler { _, e -> - log.error("Coroutine failed", e) -} -val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) - -// supervisorScope: child failures don't cancel siblings -supervisorScope { - launch { task1() } // Can fail independently - launch { task2() } // Continues if task1 fails -} -``` - -## Nostr-Specific Patterns - -### Relay Connection Pool -```kotlin -class RelayPool(private val scope: CoroutineScope) { - fun connect(url: String) { - scope.launch { - supervisorScope { - launch { connection.receiveLoop() } - launch { connection.sendLoop() } - } - } - } - - fun observeEvents(): Flow = relays.values - .map { it.events } - .merge() - .distinctBy { it.id } -} -``` - -### Subscription Management -```kotlin -fun subscribe(filters: List): Flow = channelFlow { - val subId = UUID.randomUUID().toString() - try { - relayPool.activeRelays.collect { relays -> - relays.forEach { relay -> - launch { relay.subscribe(subId, filters).collect { send(it) } } - } - } - } finally { - relayPool.unsubscribe(subId) - } -} -``` - -## Workflow - -### 1. Assess Task -- Cold stream (Flow) or hot stream (StateFlow/SharedFlow)? -- Need structured concurrency? -- Error handling strategy? - -### 2. Investigate -```bash -# Find coroutine usage -grep -r "suspend \|launch\|async\|Flow<" quartz/src/ -# Check existing patterns -grep -r "CoroutineScope\|StateFlow" quartz/src/ -``` - -### 3. Implement -- Use appropriate dispatcher -- Implement proper cancellation -- Handle exceptions at right level -- Use structured concurrency - -### 4. Test -```kotlin -@Test -fun `test async operation`() = runTest { - val result = viewModel.loadData() - advanceUntilIdle() - assertEquals(expected, result) -} -``` - -## Constraints - -- Always use structured concurrency -- Never use `GlobalScope` -- Handle cancellation cooperatively (`ensureActive()`, `yield()`) -- Use `SupervisorJob` when children should fail independently -- Prefer Flow over callbacks -- Use `flowOn` to switch dispatchers, not `withContext` in flow diff --git a/.claude/agents/kotlin-multiplatform.md b/.claude/agents/kotlin-multiplatform.md deleted file mode 100644 index 419a7c740..000000000 --- a/.claude/agents/kotlin-multiplatform.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -name: kotlin-multiplatform -description: Automatically invoked when working with KMP project structure, build.gradle.kts files, expect/actual declarations, source sets (commonMain, androidMain, jvmMain), or multiplatform migration tasks. Expert in code sharing across Android and Desktop JVM. -tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch -model: sonnet ---- - -# Kotlin Multiplatform Agent - -You are a Kotlin Multiplatform expert specializing in KMP architecture for Android and Desktop JVM targets. - -## Auto-Trigger Contexts - -Activate when user works with: -- `build.gradle.kts` files with multiplatform plugin -- Files in `commonMain/`, `androidMain/`, `jvmMain/` source sets -- `expect` or `actual` declarations -- Cross-platform library selection -- Migration from Android-only to multiplatform - -## Core Knowledge - -### Source Set Hierarchy -``` - commonMain - │ - ┌───────────┼───────────┐ - │ │ │ - jvmMain nativeMain jsMain - │ │ - ┌──────┴───┐ ┌───┴───┐ - │ │ │ │ -androidMain desktopMain iosMain -``` - -### expect/actual Pattern -```kotlin -// commonMain - Declaration -expect class PlatformContext -expect fun getPlatform(): Platform - -// androidMain -actual class PlatformContext(val context: Context) -actual fun getPlatform(): Platform = Platform.Android - -// jvmMain (Desktop) -actual class PlatformContext -actual fun getPlatform(): Platform = Platform.Desktop -``` - -### Platform Dependencies - -| Concern | Android | Desktop JVM | -|---------|---------|-------------| -| **Crypto** | secp256k1-kmp-jni-android | secp256k1-kmp-jni-jvm | -| **Storage** | Android KeyStore | Java KeyStore / File | -| **Sodium** | lazysodium-android | lazysodium-java | -| **UI** | Jetpack Compose | Compose Desktop | - -## Workflow - -### 1. Assess Task -- Identify if task involves shared vs platform-specific code -- Check existing source set structure -- Understand dependency requirements - -### 2. Investigate -```bash -# Check existing structure -ls -la quartz/src/ -# Find expect declarations -grep -r "expect " quartz/src/commonMain/ -# Find actual implementations -grep -r "actual " quartz/src/androidMain/ quartz/src/jvmMain/ -``` - -### 3. Implement -- Place shared code in `commonMain` -- Create `expect` declarations for platform APIs -- Implement `actual` in each target source set -- Update `build.gradle.kts` dependencies per source set - -### 4. Verify -```bash -./gradlew :quartz:build -./gradlew :desktopApp:compileKotlinJvm -``` - -## Quartz KMP Structure - -``` -quartz/src/ -├── commonMain/kotlin/ # Shared Nostr protocol -├── commonTest/kotlin/ # Shared tests -├── androidMain/kotlin/ # Android crypto, storage -└── jvmMain/kotlin/ # Desktop crypto, storage -``` - -## Key Abstractions for This Project - -- `CryptoProvider` - secp256k1 signing/verification -- `SodiumProvider` - NIP-44 encryption -- `SecureStorage` - Key storage -- `PlatformContext` - Platform-specific context - -## Constraints - -- Maximize code in `commonMain` (target 70-80%) -- Use KMP-compatible libraries only in commonMain -- Platform implementations must have identical signatures -- Run tests on all targets before completing - -## Resources - -Reference these GitHub repositories for KMP patterns and libraries: - -| Repository | Focus | Key Examples | -|------------|-------|--------------| -| [joreilly](https://github.com/joreilly) | KMP samples | PeopleInSpace, Confetti, GeminiKMP | -| [touchlab](https://github.com/touchlab) | KMP tooling | Kermit (logging), Stately (state), SKIE | -| [cashapp](https://github.com/cashapp) | KMP libraries | SQLDelight, Turbine, Molecule | - -**Useful libraries from these sources:** -- `SQLDelight` - Type-safe SQL for all platforms -- `Kermit` - Multiplatform logging -- `Turbine` - Testing Kotlin Flows -- `Molecule` - Build UI state with Compose diff --git a/.claude/agents/nostr-protocol.md b/.claude/agents/nostr-protocol.md deleted file mode 100644 index ea6073f66..000000000 --- a/.claude/agents/nostr-protocol.md +++ /dev/null @@ -1,168 +0,0 @@ ---- -name: nostr-protocol -description: Automatically invoked when working with Nostr events, NIPs, relay communication, cryptographic operations (signing, encryption), or Quartz library code for protocol implementation. -tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch -model: sonnet ---- - -# Nostr Protocol Agent - -You are a Nostr Protocol expert specializing in NIPs, events, relays, and cryptographic operations. - -## Auto-Trigger Contexts - -Activate when user works with: -- Event classes (kind, tags, content, sig) -- NIP implementations -- Relay WebSocket communication -- Cryptographic operations (secp256k1, NIP-44) -- Quartz library protocol code -- Filters and subscriptions - -## Core Knowledge - -### Event Structure -```kotlin -data class Event( - val id: String, // SHA256 of serialized event - val pubkey: String, // 32-byte hex public key - val created_at: Long, // Unix timestamp - val kind: Int, // Event type - val tags: List>, - val content: String, - val sig: String // 64-byte Schnorr signature -) - -// Event ID = SHA256([0, pubkey, created_at, kind, tags, content]) -``` - -### NIP Categories - -| Category | NIPs | Scope | -|----------|------|-------| -| **Core** | 01, 02, 10, 11 | Basic events, follows, threads, relay info | -| **Messaging** | 04, 17, 44 | DMs, encrypted messaging | -| **Social** | 18, 25, 32, 51 | Reactions, reports, lists | -| **Identity** | 05, 19, 39, 46 | DNS, bech32, bunker, signer | -| **Media** | 23, 30, 54, 94 | Long-form, audio, video, blobs | -| **Payments** | 47, 57, 60 | Wallet Connect, zaps, cashu | - -### Relay Messages -``` -Client -> Relay: - ["REQ", , ...] # Subscribe - ["EVENT", ] # Publish - ["CLOSE", ] # Unsubscribe - -Relay -> Client: - ["EVENT", , ] # Event received - ["EOSE", ] # End of stored events - ["OK", , , ] # Publish result - ["NOTICE", ] # Info message -``` - -### Cryptographic Operations - -**Signing (Schnorr/BIP-340)** -```kotlin -val signature = Secp256k1.sign( - data = eventHash, - privateKey = privateKeyBytes -) -``` - -**NIP-44 Encryption (Modern)** -```kotlin -val ciphertext = Nip44.encrypt( - plaintext = message, - sharedSecret = computeSharedSecret(myPrivKey, theirPubKey) -) -``` - -**NIP-04 Encryption (Deprecated)** -```kotlin -// AES-256-CBC - only for legacy compatibility -val ciphertext = Nip04.encrypt(message, sharedSecret) -``` - -### Common Event Kinds - -| Kind | NIP | Description | -|------|-----|-------------| -| 0 | 01 | Metadata (profile) | -| 1 | 01 | Short text note | -| 3 | 02 | Follows list | -| 4 | 04 | Encrypted DM (deprecated) | -| 7 | 25 | Reaction | -| 1984 | 32 | Report | -| 30023 | 23 | Long-form content | - -### Tag Patterns -```kotlin -// Reply threading (NIP-10) -tags = listOf( - listOf("e", rootEventId, relayUrl, "root"), - listOf("e", replyToId, relayUrl, "reply"), - listOf("p", authorPubkey) -) - -// Mentions -tags = listOf( - listOf("p", mentionedPubkey), - listOf("t", "hashtag") -) -``` - -## Workflow - -### 1. Assess Task -- Which NIP(s) are involved? -- Event creation or parsing? -- Relay communication? -- Crypto operations needed? - -### 2. Investigate -```bash -# Check existing NIP implementations -grep -r "kind.*=" quartz/src/ -# Find event classes -grep -r "class.*Event" quartz/src/ -# Check crypto usage -grep -r "Secp256k1\|Nip44\|sign\|verify" quartz/src/ -``` - -### 3. Reference NIP Spec -```bash -# Fetch NIP specification -curl https://raw.githubusercontent.com/nostr-protocol/nips/master/XX.md -``` - -### 4. Implement -- Follow NIP spec exactly -- Use existing Quartz patterns -- Validate event structure -- Test signature verification - -### 5. Verify -```bash -./gradlew :quartz:test -``` - -## Quartz Library Structure -``` -quartz/src/commonMain/kotlin/ -├── events/ # Event types per NIP -├── encoders/ # Bech32, hex encoding -├── crypto/ # Signing, encryption -├── relay/ # WebSocket communication -└── filters/ # Subscription filters -``` - -## Constraints - -- Always follow NIP specifications exactly -- Use NIP-44 for new encryption (not NIP-04) -- Validate all incoming events (sig, id) -- Never log private keys or decrypted content -- Use existing Quartz classes when available -- Test with real relay responses when possible diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md new file mode 100644 index 000000000..6b51cdab0 --- /dev/null +++ b/.claude/core-skills-plan.md @@ -0,0 +1,322 @@ +# AmethystMultiplatform Skills Creation Plan + +## Overview +Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns. + +**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase. + +## Skills to Implement + +### 1. kotlin-multiplatform ✅ COMPLETED +**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual + +**SKILL.md sections:** +- Mental model: KMP hierarchy as dependency graph +- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain} +- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149) +- expect/actual mechanics with 24+ examples from codebase +- iOS framework setup for Quartz distribution + +**Bundled resources:** +- `references/source-set-hierarchy.md` - Visual diagram + examples +- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns +- `scripts/validate-kmp-structure.sh` - Verify source set dependencies +- `assets/kmp-hierarchy-diagram.png` - Visual graph + +**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples. + +**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/` + +--- + +### 2. gradle-expert ✅ COMPLETED +**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting + +**SKILL.md sections:** +- Build architecture: 4 modules, dependency flow +- Version catalog mastery (libs.versions.toml) +- Module dependency patterns (api vs implementation) +- Android-specific: compileSdk, proguard +- Desktop packaging: TargetFormat, distributions +- Build performance: daemon, parallel, caching +- Common errors: compose version conflicts, secp256k1 JNI variants + +**Bundled resources:** +- `references/build-commands.md` - Common gradle tasks +- `references/dependency-graph.md` - Module visualization +- `references/version-catalog-guide.md` - Version catalog patterns +- `references/common-errors.md` - Troubleshooting guide +- `scripts/analyze-build-time.sh` - Performance report +- `scripts/fix-dependency-conflicts.sh` - Conflict patterns + +**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts). + +**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/` + +--- + +### 3. kotlin-expert ✅ DRAFT COMPLETE +**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified + +**SKILL.md sections:** +- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager) +- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult) +- Immutability: @Immutable for Compose performance (173+ event classes) +- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder) +- Inline functions: reified generics, performance optimization (OptimizedJsonMapper) +- Value classes: Zero-cost wrappers (optimization opportunity) + +**Bundled resources:** +- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns +- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns +- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns +- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set + +**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders). + +**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/` + +**10-Step Progress:** +1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline) +2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder +3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025 +4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf) +5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability) +6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) +7. ✅ ITERATE - Draft complete (skipping deep iteration for now) +8. ⏸️ TEST - Deferred to later (requires real usage scenarios) +9. ⏸️ FINALIZE - Deferred to later +10. ✅ DOCUMENT - Updated plan + +--- + +### 4. compose-expert ✅ COMPLETED +**Focus:** Shared composables, state management, animations, Material3 + +**SKILL.md sections:** +- Shared composables philosophy (100+ already shared in commons/commonMain) +- State management: remember, derivedStateOf, produceState (visual patterns) +- Recomposition optimization: @Stable/@Immutable (visual usage) +- Material3 conventions: theming +- Custom icons: ImageVector builders (robohash pattern) +- Platform differences: Desktop vs Android UI +- Performance: lazy lists, image loading +- Decision framework: share by default in commonMain + +**Bundled resources:** +- `references/shared-composables-catalog.md` - Complete catalog with patterns +- `references/state-patterns.md` - State hoisting, derivedStateOf examples +- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL +- `scripts/find-composables.sh` - Grep @Composable utility + +**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert. + +**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/` + +--- + +### 5. ios-expert +**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation + +**SKILL.md sections:** +- iOS source sets: iosMain, iosX64Main, iosArm64Main +- Swift interop: type mapping, nullability +- expect/actual iOS: 10+ examples from quartz/iosMain +- XCFramework setup: baseName = "quartz-kmpKit" +- Platform APIs: platform.posix, CFNetwork, Security +- CocoaPods integration +- XCode project setup + +**Bundled resources:** +- `references/ios-actual-implementations.md` - 10 iosMain actuals +- `references/swift-interop-guide.md` - Type mapping +- `references/xcode-integration.md` - XCode setup +- `scripts/generate-xcframework.sh` - Build all iOS targets + +**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup. + +--- + +### 6. desktop-expert ✅ DRAFT COMPLETE +**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions + +**SKILL.md sections:** +- Desktop entry point: application {} DSL +- Window management: WindowState, positioning, multi-window +- Menu system: MenuBar, keyboard shortcuts (OS-aware) +- System tray: minimize to tray +- Desktop navigation: NavigationRail pattern (vs Android bottom nav) +- File system: Desktop.getDesktop(), file pickers, drag-drop +- Desktop UX principles: keyboard-first, native feel, tooltips +- OS-specific behavior: macOS vs Windows vs Linux +- Platform detection: PlatformDetector utility +- Packaging: DMG, MSI, DEB distribution + +**Bundled resources:** +- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.) +- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns +- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper +- `references/os-detection.md` - Platform detection, file paths, system integration + +**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert. + +**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/` + +**10-Step Progress:** +1. ✅ UNDERSTAND - Defined desktop usage scenarios +2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt) +3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG) +4. ✅ SYNTHESIZE - Extracted desktop principles from codebase +5. ✅ DRAFT - Created SKILL.md + 4 reference files +6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) +7. ✅ ITERATE - Draft complete (skipping deep iteration for now) +8. ⏸️ TEST - Deferred to later (requires real desktop scenarios) +9. ⏸️ FINALIZE - Deferred to later +10. ✅ DOCUMENT - Updated plan + +--- + +### 7. android-expert ✅ DRAFT COMPLETE +**Focus:** Android platform APIs, navigation, permissions, Material Design + +**SKILL.md sections:** +- Android module structure: amethyst/ layout +- Navigation: Navigation Compose, bottom nav +- Permissions: runtime (camera, biometric) +- Platform APIs: Intent, Context, ContentResolver +- Lifecycle: Lifecycle-aware, ViewModel +- Material Design: Android Material 3 +- Build config: Proguard, R8 +- Android UX: mobile-first patterns + +**Bundled resources:** +- `references/android-navigation.md` - Navigation Compose +- `references/android-permissions.md` - Permission handling +- `references/proguard-rules.md` - Proguard explanation +- `scripts/analyze-apk-size.sh` - APK optimization + +**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions. + +**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/` + +**10-Step Progress:** +1. ✅ UNDERSTAND - Defined Android usage scenarios +2. ✅ EXPLORE - Analyzed amethyst/ module patterns +3. ✅ RESEARCH - Android best practices + KMP Android patterns +4. ✅ SYNTHESIZE - Extracted Android principles from codebase +5. ✅ DRAFT - Initialized skill, created resources +6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) +7. ✅ ITERATE - Draft complete (skipping deep iteration for now) +8. ⏸️ TEST - Deferred to later +9. ⏸️ FINALIZE - Deferred to later +10. ✅ DOCUMENT - Updated plan + +--- + +### 8. nostr-expert ✅ COMPLETED +**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns + +**SKILL.md sections:** +- Quartz architecture: package structure by NIP (57 NIPs implemented) +- Event anatomy: IEvent, Event, kinds, tags +- EventTemplate & TagArrayBuilder DSL patterns +- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events +- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders +- Threading (NIP-10): reply/root markers +- Cryptography: secp256k1 signing, NIP-44 encryption +- Bech32 encoding: npub, nsec, note, nevent +- Event validation & verification +- Common workflows: publishing, querying, zaps, gift-wrapped DMs + +**Bundled resources:** +- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines) +- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines) +- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines) +- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term + +**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase. + +**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/` + +--- + +## Implementation Workflow + +Using skill-creator 10-step methodology per skill: + +**Overall Plan:** +1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained +2. **EXPLORE** ✅ - Codebase analyzed via Explore agent +3. **RESEARCH** ✅ - Domain patterns identified via Plan agent +4. **SYNTHESIZE** ✅ - Skills designed above + +**Per-Skill Implementation:** +- kotlin-multiplatform: ✅ COMPLETED +- gradle-expert: ✅ COMPLETED +- kotlin-expert: ✅ COMPLETED +- compose-expert: ✅ COMPLETED +- desktop-expert: ✅ COMPLETED +- android-expert: ✅ COMPLETED +- nostr-expert: ✅ COMPLETED +- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform) + +## Critical Files Referenced + +**Build patterns:** +- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set +- `/commons/build.gradle.kts` - Shared UI setup + +**Code patterns:** +- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure +- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern +- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual + +**Documentation:** +- `/docs/shared-ui-analysis.md` - UI migration strategy + +## Output Location +`.claude/skills//` for each skill + +## Next Steps + +1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference +2. ✅ Completed kotlin-multiplatform skill +3. ✅ Completed gradle-expert skill +4. ✅ Completed kotlin-expert skill +5. ✅ Completed compose-expert skill +6. ✅ Completed desktop-expert skill +7. ✅ Completed android-expert skill +8. ✅ Completed nostr-expert skill +9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase) + +## Current Status: 7/8 Skills Completed + +**Completed Skills (Auto-loaded from `.claude/skills/`):** +1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual) +2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting) +3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders) +4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector) +5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs) +6. ✅ android-expert (Android platform APIs, navigation, permissions) +7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags) + +**Deferred:** +- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform) + +## Skill Loading + +**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory. + +To manually verify skills are loaded: +```bash +ls -1 .claude/skills/ +``` + +Should show: +- android-expert/ +- compose-expert/ +- desktop-expert/ +- gradle-expert/ +- kotlin-expert/ +- kotlin-multiplatform/ +- nostr-expert/ diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md new file mode 100644 index 000000000..7754d5802 --- /dev/null +++ b/.claude/skills/android-expert/SKILL.md @@ -0,0 +1,1066 @@ +# android-expert + +Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture. + +## When to Use + +Auto-invoke when working with: +- Android navigation (Navigation Compose, routes, bottom nav) +- Runtime permissions (camera, notifications, biometric) +- Platform APIs (Intent, Context, Activity) +- Material3 theming and edge-to-edge UI +- Android build configuration (Proguard, APK optimization) +- AndroidManifest.xml configuration +- Android lifecycle (ViewModel, collectAsStateWithLifecycle) + +## Core Mental Model + +**Single Activity Architecture + Compose Navigation** + +``` +MainActivity (Single Entry Point) + ├── enableEdgeToEdge() + ├── AmethystTheme { } + └── NavHost + ├── Route.Home → HomeScreen + ├── Route.Profile(id) → ProfileScreen + └── Route.Settings → SettingsScreen + +Intent Filters (11+) + ├── ACTION_MAIN (launcher) + ├── ACTION_SEND (share) + ├── ACTION_VIEW (deep links: nostr://, https://...) + └── NFC_ACTION_NDEF_DISCOVERED +``` + +**Key Principles:** +1. **Type-Safe Navigation** - @Serializable routes, no strings +2. **Declarative Permissions** - Request contextually with Accompanist +3. **Edge-to-Edge + Insets** - Scaffold handles system bars +4. **ViewModel + Flow → State** - Survive config changes +5. **Platform Isolation** - Android code in `amethyst/` module or `androidMain/` + +## Architecture Overview + +### Module Structure + +``` +amethyst/ # Android app module +├── src/ +│ ├── main/ +│ │ ├── java/com/vitorpamplona/amethyst/ +│ │ │ ├── ui/ +│ │ │ │ ├── MainActivity.kt # Entry point +│ │ │ │ ├── navigation/ +│ │ │ │ │ ├── AppNavigation.kt # NavHost +│ │ │ │ │ ├── routes/Routes.kt # @Serializable routes +│ │ │ │ │ └── bottombars/AppBottomBar.kt +│ │ │ │ ├── screen/ # 80+ screens +│ │ │ │ └── theme/Theme.kt # Material3 theme +│ │ │ └── Amethyst.kt # Application class +│ │ └── AndroidManifest.xml # Permissions, intent filters +│ └── androidMain/ # KMP Android source set +│ └── kotlin/ # Platform-specific code +└── build.gradle # Android config +``` + +## 1. Type-Safe Navigation + +### Pattern: @Serializable Routes + +**Best Practice (Navigation 2.8.0+):** +```kotlin +// Routes.kt - Define all routes with type safety +@Serializable +sealed class Route { + @Serializable object Home : Route() + @Serializable object Search : Route() + @Serializable data class Profile(val pubkey: String) : Route() + @Serializable data class Note(val noteId: String) : Route() + @Serializable data class Thread(val noteId: String) : Route() +} + +// AppNavigation.kt - NavHost setup +@Composable +fun AppNavigation( + navController: NavHostController, + accountViewModel: AccountViewModel +) { + NavHost( + navController = navController, + startDestination = Route.Home, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) } + ) { + composable { + HomeScreen(accountViewModel, navController) + } + + composable { backStackEntry -> + val profile = backStackEntry.toRoute() + ProfileScreen(profile.pubkey, accountViewModel, navController) + } + + composable { backStackEntry -> + val note = backStackEntry.toRoute() + NoteScreen(note.noteId, accountViewModel, navController) + } + } +} +``` + +### Navigation Manager Pattern + +**Amethyst Pattern (`Nav.kt`):** +```kotlin +class Nav( + val controller: NavHostController, + val drawerState: DrawerState, + val scope: CoroutineScope +) { + fun nav(route: Route) { + scope.launch { + controller.navigate(route) + drawerState.close() + } + } + + fun newStack(route: Route) { + scope.launch { + controller.navigate(route) { + popUpTo(Route.Home) { inclusive = false } + } + drawerState.close() + } + } + + fun popBack() { + controller.popBackStack() + } +} + +// Usage in composables +@Composable +fun HomeScreen(nav: Nav) { + Button(onClick = { nav.nav(Route.Profile("npub1...")) }) { + Text("View Profile") + } +} +``` + +### Bottom Navigation + +**Material3 Pattern:** +```kotlin +@Composable +fun AppBottomBar( + selectedRoute: Route, + nav: Nav +) { + NavigationBar { + BottomBarItem.entries.forEach { item -> + NavigationBarItem( + selected = selectedRoute::class == item.route::class, + onClick = { nav.nav(item.route) }, + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label) } + ) + } + } +} + +enum class BottomBarItem(val route: Route, val icon: ImageVector, val label: String) { + HOME(Route.Home, Icons.Default.Home, "Home"), + MESSAGES(Route.Messages, Icons.Default.Message, "Messages"), + NOTIFICATIONS(Route.Notifications, Icons.Default.Notifications, "Notifications"), + SEARCH(Route.Search, Icons.Default.Search, "Search"), + PROFILE(Route.Profile, Icons.Default.Person, "Profile") +} +``` + +**Reference:** See `references/android-navigation.md` for complete navigation patterns. + +## 2. Runtime Permissions + +### Declarative Permission Handling + +**Accompanist Pattern (Experimental API):** +```kotlin +import com.google.accompanist.permissions.* + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraFeature() { + val cameraPermissionState = rememberPermissionState( + Manifest.permission.CAMERA + ) + + when { + cameraPermissionState.status.isGranted -> { + // Permission granted - show camera UI + CameraPreview() + } + + cameraPermissionState.status.shouldShowRationale -> { + // Show rationale and request again + Column { + Text("Camera permission is needed to scan QR codes") + Button( + onClick = { cameraPermissionState.launchPermissionRequest() } + ) { + Text("Grant Permission") + } + } + } + + else -> { + // First time - request permission + Button( + onClick = { cameraPermissionState.launchPermissionRequest() } + ) { + Text("Enable Camera") + } + } + } +} +``` + +### Multiple Permissions + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MediaUploadFeature() { + val permissionsState = rememberMultiplePermissionsState( + permissions = listOf( + Manifest.permission.CAMERA, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + ) + + when { + permissionsState.allPermissionsGranted -> { + MediaUploadUI() + } + + permissionsState.shouldShowRationale -> { + RationaleDialog( + onConfirm = { permissionsState.launchMultiplePermissionRequest() }, + onDismiss = { /* Handle dismissal */ } + ) + } + + else -> { + PermissionRequestButton( + onClick = { permissionsState.launchMultiplePermissionRequest() } + ) + } + } +} +``` + +### Lifecycle-Aware Permission Requests + +**Amethyst Pattern (LoggedInPage.kt):** +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun NotificationRegistration(accountViewModel: AccountViewModel) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermissionState = rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS + ) + + if (notificationPermissionState.status.isGranted) { + LifecycleResumeEffect( + key1 = accountViewModel, + notificationPermissionState.status.isGranted + ) { + val scope = rememberCoroutineScope() + scope.launch { + PushNotificationUtils.checkAndInit( + context = context, + accountViewModel = accountViewModel + ) + } + + onPauseOrDispose { + // Cleanup when paused + } + } + } + } +} +``` + +### AndroidManifest Permission Declarations + +**Key Permissions in Amethyst:** +```xml + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**Reference:** See `references/android-permissions.md` for complete permission patterns. + +## 3. Material3 + Edge-to-Edge + +### Edge-to-Edge Setup + +**MainActivity Pattern:** +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() // Android 15+ immersive UI + super.onCreate(savedInstanceState) + + setContent { + AmethystTheme { + AccountScreen() + } + } + } +} +``` + +### Theme Configuration + +**Material3 Color Schemes:** +```kotlin +// theme/Theme.kt +private val DarkColorPalette = darkColorScheme( + primary = Purple200, + secondary = Teal200, + tertiary = Pink80, + background = Color.Black, + surface = Color.Black, + onPrimary = Color.White, + onSecondary = Color.Black, + onBackground = Color.White, + onSurface = Color.White +) + +private val LightColorPalette = lightColorScheme( + primary = Purple500, + secondary = Teal700, + tertiary = Pink40, + background = Color.White, + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = Color.Black, + onSurface = Color.Black +) + +@Composable +fun AmethystTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorPalette else LightColorPalette + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} +``` + +### Scaffold with Insets + +**Handling System Bars:** +```kotlin +@Composable +fun MainScreen(navController: NavHostController) { + val currentRoute by navController.currentBackStackEntryAsState() + + Scaffold( + topBar = { AppTopBar(currentRoute) }, + bottomBar = { AppBottomBar(currentRoute, navController) }, + floatingActionButton = { NewPostFab() } + ) { innerPadding -> + // Scaffold automatically handles system bar insets + NavHost( + navController = navController, + modifier = Modifier.padding(innerPadding) + ) { + // Routes... + } + } +} +``` + +**Custom Inset Handling:** +```kotlin +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsPadding + +@Composable +fun CustomEdgeToEdgeScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() // Add padding for system bars + ) { + // Content draws edge-to-edge with safe padding + } +} +``` + +## 4. ViewModel + Lifecycle + +### ViewModel Pattern + +**Standard Structure (80+ ViewModels in Amethyst):** +```kotlin +class FeedViewModel( + private val accountStateViewModel: AccountStateViewModel +) : ViewModel() { + + private val _feedState = MutableStateFlow(FeedState.Loading) + val feedState: StateFlow = _feedState.asStateFlow() + + init { + loadFeed() + } + + fun loadFeed() { + viewModelScope.launch { + _feedState.value = FeedState.Loading + try { + val posts = repository.getFeed() + _feedState.value = FeedState.Success(posts) + } catch (e: Exception) { + _feedState.value = FeedState.Error(e.message) + } + } + } + + fun refresh() { + loadFeed() + } +} + +sealed class FeedState { + object Loading : FeedState() + data class Success(val posts: List) : FeedState() + data class Error(val message: String?) : FeedState() +} +``` + +### Compose Integration + +**collectAsStateWithLifecycle Pattern:** +```kotlin +@Composable +fun FeedScreen( + feedViewModel: FeedViewModel = viewModel() +) { + val feedState by feedViewModel.feedState.collectAsStateWithLifecycle() + + when (feedState) { + is FeedState.Loading -> { + LoadingIndicator() + } + is FeedState.Success -> { + val posts = (feedState as FeedState.Success).posts + LazyColumn { + items(posts) { post -> + PostCard(post) + } + } + } + is FeedState.Error -> { + ErrorScreen( + message = (feedState as FeedState.Error).message, + onRetry = { feedViewModel.refresh() } + ) + } + } +} +``` + +### Lifecycle Effects + +**LifecycleResumeEffect Pattern:** +```kotlin +@Composable +fun ChatScreen(chatViewModel: ChatViewModel) { + LifecycleResumeEffect(key1 = chatViewModel) { + // Called when composable resumes (onResume) + chatViewModel.connectToRelay() + + onPauseOrDispose { + // Called when composable pauses (onPause) or disposes + chatViewModel.disconnectFromRelay() + } + } +} +``` + +**DisposableEffect for Cleanup:** +```kotlin +@Composable +fun VideoPlayer(videoUrl: String) { + val context = LocalContext.current + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(videoUrl)) + prepare() + } + } + + DisposableEffect(videoUrl) { + onDispose { + exoPlayer.release() + } + } + + AndroidView( + factory = { PlayerView(it).apply { player = exoPlayer } } + ) +} +``` + +## 5. Platform APIs + +### Activity & Context Access + +**LocalContext Pattern:** +```kotlin +@Composable +fun ShareButton(text: String) { + val context = LocalContext.current + + Button( + onClick = { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + context.startActivity(Intent.createChooser(intent, "Share via")) + } + ) { + Text("Share") + } +} +``` + +**Activity Reference:** +```kotlin +// WindowUtils.kt pattern +@Composable +fun getActivity(): Activity? = LocalContext.current.getActivity() + +tailrec fun Context.getActivity(): ComponentActivity = + when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> throw IllegalStateException("Context not an Activity") + } + +// Usage +@Composable +fun FullscreenToggle() { + val activity = getActivity() + + Button( + onClick = { + activity?.window?.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ) + } + ) { + Text("Go Fullscreen") + } +} +``` + +### Intent Handling + +**Deep Links (AppNavigation.kt pattern):** +```kotlin +@Composable +fun AppNavigation( + navController: NavHostController, + accountViewModel: AccountViewModel +) { + val activity = LocalContext.current as? Activity + + LaunchedEffect(activity?.intent) { + activity?.intent?.let { intent -> + when (intent.action) { + Intent.ACTION_SEND -> { + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + val sharedImage = intent.getParcelableExtra(Intent.EXTRA_STREAM) + navController.navigate( + Route.NewPost(message = sharedText, attachment = sharedImage.toString()) + ) + } + + Intent.ACTION_VIEW -> { + val uri = intent.data + when (uri?.scheme) { + "nostr" -> handleNostrUri(uri, navController) + "https", "http" -> handleWebUri(uri, navController) + } + } + } + } + } + + NavHost(navController = navController) { + // Routes... + } +} + +fun handleNostrUri(uri: Uri, navController: NavHostController) { + // nostr:npub1... -> Profile + // nostr:note1... -> Note + // nostr:nevent1... -> Event + when { + uri.path?.startsWith("npub") == true -> { + navController.navigate(Route.Profile(uri.path!!)) + } + uri.path?.startsWith("note") == true -> { + navController.navigate(Route.Note(uri.path!!)) + } + } +} +``` + +### File Sharing with FileProvider + +**ShareHelper Pattern:** +```kotlin +fun shareImage(context: Context, imageUri: Uri) { + try { + // Get file from cache + val cachedFile = getCachedFile(context, imageUri) + + // Create content URI via FileProvider + val contentUri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + cachedFile + ) + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "image/*" + putExtra(Intent.EXTRA_STREAM, contentUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(Intent.createChooser(shareIntent, "Share Image")) + } catch (e: Exception) { + Toast.makeText(context, "Failed to share: ${e.message}", Toast.LENGTH_SHORT).show() + } +} +``` + +**FileProvider Configuration (AndroidManifest.xml):** +```xml + + + +``` + +### Activity Results + +**External Signer Integration (Amethyst pattern):** +```kotlin +@Composable +fun SignerIntegration(accountViewModel: AccountViewModel) { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.let { data -> + accountViewModel.account.signer.newResponse(data) + } + } + } + + Button( + onClick = { + val signerIntent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("nostrsigner:...") + } + launcher.launch(signerIntent) + } + ) { + Text("Sign with External App") + } +} +``` + +## 6. Build Configuration + +### Android Block + +**build.gradle (Amethyst pattern):** +```gradle +android { + namespace = 'com.vitorpamplona.amethyst' + compileSdk = 36 + + defaultConfig { + applicationId = "com.vitorpamplona.amethyst" + minSdk = 26 // Android 8.0 (Oreo) + targetSdk = 36 // Android 15 + versionCode = 430 + versionName = "1.04.2" + + vectorDrawables { + useSupportLibrary = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + compose = true + buildConfig = true // Enable BuildConfig access + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + + packaging { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } + + // Product flavors for Play Store vs F-Droid + flavorDimensions = ["channel"] + productFlavors { + create("play") { + dimension = "channel" + // Firebase, Google services + } + create("fdroid") { + dimension = "channel" + // UnifiedPush, open-source alternatives + } + } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_21 + } +} +``` + +### Dependencies + +**Key Android Dependencies:** +```gradle +dependencies { + // Compose BOM + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + + // Navigation + implementation(libs.androidx.navigation.compose) + + // Lifecycle + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Activity + implementation(libs.androidx.activity.compose) + + // Accompanist + implementation(libs.accompanist.permissions) + + // Shared module + implementation(project(":commons")) + implementation(project(":quartz")) +} +``` + +### Proguard Rules + +**Common Rules for Amethyst:** +```proguard +# Keep Kotlin metadata +-keep class kotlin.Metadata { *; } + +# Keep Nostr event classes +-keep class com.vitorpamplona.quartz.events.** { *; } + +# Keep serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt + +# OkHttp +-dontwarn okhttp3.** +-keep class okhttp3.** { *; } + +# Compose +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** +``` + +**Reference:** See `references/proguard-rules.md` for complete Proguard configuration. + +### APK Optimization + +**Reference:** See `scripts/analyze-apk-size.sh` for APK size analysis. + +## 7. KMP Android Source Sets + +### Android Module Layout + +**Amethyst Structure:** +``` +amethyst/ +├── src/ +│ ├── main/ # Standard Android +│ │ ├── java/com/.../ # Compose UI code +│ │ ├── res/ # Android resources +│ │ └── AndroidManifest.xml +│ └── androidMain/ # KMP Android source set (if needed) +│ └── kotlin/ # Platform-specific utilities +└── build.gradle +``` + +**Platform-Specific Code:** +```kotlin +// commons/src/androidMain/kotlin/Platform.android.kt +actual fun openExternalUrl(url: String, context: Any) { + val ctx = context as Context + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + ctx.startActivity(intent) +} + +actual fun shareText(text: String, context: Any) { + val ctx = context as Context + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + ctx.startActivity(Intent.createChooser(intent, "Share")) +} +``` + +### Build Configuration for KMP + +```gradle +kotlin { + androidTarget { + compilerOptions { + jvmTarget = JvmTarget.JVM_21 + } + } +} + +android { + sourceSets { + // Link androidMain source set + getByName("main") { + manifest.srcFile("src/main/AndroidManifest.xml") + java.srcDirs("src/main/java", "src/androidMain/kotlin") + } + } +} +``` + +## Common Patterns + +### 1. Single Activity Architecture + +**All screens in one activity, navigation via Compose:** +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setContent { + AmethystTheme { + val accountViewModel: AccountStateViewModel = viewModel() + AccountScreen(accountViewModel) + } + } + } + + override fun onResume() { + super.onResume() + DEFAULT_MUTED_SETTING.value = true + } + + override fun onPause() { + super.onPause() + LanguageTranslatorService.clear() + } +} +``` + +### 2. Configuration Changes + +**ViewModels survive rotation:** +```kotlin +// ViewModel persists across config changes +@Composable +fun ProfileScreen( + profileViewModel: ProfileViewModel = viewModel() +) { + val profile by profileViewModel.profile.collectAsStateWithLifecycle() + + // UI rebuilds on rotation, but ViewModel data persists + ProfileContent(profile) +} +``` + +### 3. Resource Access + +```kotlin +@Composable +fun LocalizedButton() { + val context = LocalContext.current + + Button( + onClick = { + val message = context.getString(R.string.button_clicked) + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + ) { + Text(stringResource(R.string.button_label)) + } +} +``` + +## Testing Android Components + +### Navigation Testing + +```kotlin +@Test +fun testNavigationToProfile() { + val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) + + composeTestRule.setContent { + navController.navigatorProvider.addNavigator(ComposeNavigator()) + AppNavigation(navController, accountViewModel) + } + + composeTestRule.onNodeWithText("Profile").performClick() + + assertEquals( + Route.Profile::class, + navController.currentBackStackEntry?.destination?.route::class + ) +} +``` + +### Permission Testing + +```kotlin +@Test +fun testPermissionRequest() { + val scenario = launchActivity() + + scenario.onActivity { activity -> + // Grant permission via UiAutomator + grantPermissionViaUi(Manifest.permission.CAMERA) + } + + composeTestRule.onNodeWithText("Camera Ready").assertExists() +} +``` + +## Anti-Patterns to Avoid + +1. **String-based navigation** - Use type-safe @Serializable routes +2. **Requesting permissions eagerly** - Request contextually before feature use +3. **Ignoring edge-to-edge** - Handle insets properly with Scaffold +4. **Using GlobalScope** - Use viewModelScope or rememberCoroutineScope +5. **Not handling config changes** - Use ViewModel + collectAsStateWithLifecycle +6. **Hardcoded system bar heights** - Use WindowInsets APIs +7. **Blocking main thread** - Use viewModelScope.launch(Dispatchers.IO) + +## Quick Reference + +| Task | Pattern | +|------|---------| +| **Navigate** | `navController.navigate(Route.Profile(id))` | +| **Request Permission** | `rememberPermissionState().launchPermissionRequest()` | +| **Access Context** | `val context = LocalContext.current` | +| **Get Activity** | `val activity = context.getActivity()` | +| **Open URL** | `Intent(ACTION_VIEW, Uri.parse(url))` | +| **Share Text** | `Intent(ACTION_SEND).putExtra(EXTRA_TEXT, text)` | +| **Observe Flow** | `flow.collectAsStateWithLifecycle()` | +| **Lifecycle Effect** | `LifecycleResumeEffect { ... }` | +| **Handle Insets** | `Modifier.systemBarsPadding()` | +| **Theme** | `MaterialTheme(colorScheme = ...) { }` | + +## File Locations + +**Key Android Files:** +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt` +- `amethyst/src/main/AndroidManifest.xml` +- `amethyst/build.gradle` + +## Additional Resources + +- `references/android-navigation.md` - Complete navigation patterns and examples +- `references/android-permissions.md` - Permission handling patterns +- `references/proguard-rules.md` - Proguard configuration +- `scripts/analyze-apk-size.sh` - APK size optimization script + +## When NOT to Use + +- Desktop-specific features → Use `desktop-expert` skill +- iOS-specific features → Use `ios-expert` skill +- Shared KMP code → Use `kotlin-multiplatform` skill +- Nostr protocol → Use `nostr-expert` skill +- Compose UI components → Use `compose-expert` skill diff --git a/.claude/skills/android-expert/references/android-navigation.md b/.claude/skills/android-expert/references/android-navigation.md new file mode 100644 index 000000000..59d8a9e31 --- /dev/null +++ b/.claude/skills/android-expert/references/android-navigation.md @@ -0,0 +1,615 @@ +# Android Navigation Patterns + +Complete navigation implementation patterns for Amethyst Android app using Navigation Compose with type safety. + +## Type-Safe Routes (Navigation 2.8.0+) + +### Route Definitions + +```kotlin +// Routes.kt - All 40+ routes in Amethyst +@Serializable +sealed class Route { + // Bottom nav routes + @Serializable object Home : Route() + @Serializable object Messages : Route() + @Serializable object Video : Route() + @Serializable object Discover : Route() + @Serializable object Notification : Route() + + // Content routes with parameters + @Serializable data class Profile(val pubkey: String) : Route() + @Serializable data class Note(val id: String) : Route() + @Serializable data class Channel(val id: String) : Route() + @Serializable data class Thread( + val id: String, + val replyTo: String? = null + ) : Route() + + // New content routes + @Serializable data class NewPost( + val message: String? = null, + val attachment: String? = null, + val replyTo: String? = null + ) : Route() + + // Settings + @Serializable object Settings : Route() + @Serializable object Security : Route() + @Serializable object Relays : Route() + + // Search + @Serializable data class Search(val query: String = "") : Route() + + // Media + @Serializable data class Image(val url: String) : Route() + @Serializable data class Video(val url: String) : Route() +} +``` + +## NavHost Configuration + +### Basic Setup + +```kotlin +@Composable +fun AppNavigation( + navController: NavHostController, + accountViewModel: AccountViewModel, + drawerState: DrawerState +) { + val scope = rememberCoroutineScope() + val nav = remember { + Nav(navController, drawerState, scope) + } + + NavHost( + navController = navController, + startDestination = Route.Home, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) }, + popEnterTransition = { fadeIn(animationSpec = tween(200)) }, + popExitTransition = { fadeOut(animationSpec = tween(200)) } + ) { + // Define routes + composable { + HomeScreen(accountViewModel, nav) + } + + composable { backStackEntry -> + val profile = backStackEntry.toRoute() + ProfileScreen( + pubkey = profile.pubkey, + accountViewModel = accountViewModel, + nav = nav + ) + } + + composable { backStackEntry -> + val note = backStackEntry.toRoute() + NoteScreen( + noteId = note.id, + accountViewModel = accountViewModel, + nav = nav + ) + } + + composable { backStackEntry -> + val newPost = backStackEntry.toRoute() + NewPostScreen( + initialMessage = newPost.message, + initialAttachment = newPost.attachment, + replyTo = newPost.replyTo, + accountViewModel = accountViewModel, + onPost = { nav.popBack() } + ) + } + } +} +``` + +### Custom Transitions + +```kotlin +composable( + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(300) + ) + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(300) + ) + }, + popEnterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(300) + ) + }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(300) + ) + } +) { backStackEntry -> + val profile = backStackEntry.toRoute() + ProfileScreen(profile.pubkey, accountViewModel, nav) +} +``` + +## Navigation Manager + +### Nav Wrapper Class + +```kotlin +class Nav( + val controller: NavHostController, + val drawerState: DrawerState, + val scope: CoroutineScope +) { + /** + * Navigate to a route, closing drawer if open + */ + fun nav(route: Route) { + scope.launch { + if (!controller.popBackStack(route, inclusive = false)) { + controller.navigate(route) { + launchSingleTop = true + } + } + drawerState.close() + } + } + + /** + * Navigate with new stack (clear back stack to Home) + */ + fun newStack(route: Route) { + scope.launch { + controller.navigate(route) { + popUpTo(Route.Home) { + inclusive = false + } + launchSingleTop = true + } + drawerState.close() + } + } + + /** + * Pop back stack + */ + fun popBack() { + controller.popBackStack() + } + + /** + * Pop up to specific route + */ + inline fun popUpTo(inclusive: Boolean = false) { + controller.popBackStack(inclusive = inclusive) + } + + /** + * Get current route + */ + fun currentRoute(): Route? { + return controller.currentBackStackEntry?.toRoute() + } +} +``` + +## Bottom Navigation + +### Material3 NavigationBar + +```kotlin +@Composable +fun AppBottomBar( + currentRoute: Route?, + nav: Nav +) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + BottomBarRoute.entries.forEach { item -> + NavigationBarItem( + selected = currentRoute?.let { it::class == item.route::class } ?: false, + onClick = { nav.nav(item.route) }, + icon = { + Icon( + imageVector = if (currentRoute?.let { it::class == item.route::class } == true) { + item.selectedIcon + } else { + item.unselectedIcon + }, + contentDescription = item.label + ) + }, + label = { Text(item.label) }, + alwaysShowLabel = false + ) + } + } +} + +enum class BottomBarRoute( + val route: Route, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val label: String +) { + HOME( + route = Route.Home, + selectedIcon = Icons.Filled.Home, + unselectedIcon = Icons.Outlined.Home, + label = "Home" + ), + MESSAGES( + route = Route.Messages, + selectedIcon = Icons.Filled.Message, + unselectedIcon = Icons.Outlined.Message, + label = "Messages" + ), + VIDEOS( + route = Route.Video, + selectedIcon = Icons.Filled.VideoLibrary, + unselectedIcon = Icons.Outlined.VideoLibrary, + label = "Videos" + ), + DISCOVER( + route = Route.Discover, + selectedIcon = Icons.Filled.Explore, + unselectedIcon = Icons.Outlined.Explore, + label = "Discover" + ), + NOTIFICATIONS( + route = Route.Notification, + selectedIcon = Icons.Filled.Notifications, + unselectedIcon = Icons.Outlined.Notifications, + label = "Notifications" + ) +} +``` + +### Observing Current Route + +```kotlin +@Composable +fun MainScreen() { + val navController = rememberNavController() + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = currentBackStackEntry?.toRoute() + + Scaffold( + topBar = { + if (shouldShowTopBar(currentRoute)) { + AppTopBar(currentRoute) + } + }, + bottomBar = { + if (shouldShowBottomBar(currentRoute)) { + AppBottomBar(currentRoute, nav) + } + } + ) { paddingValues -> + AppNavigation( + navController = navController, + modifier = Modifier.padding(paddingValues) + ) + } +} + +fun shouldShowBottomBar(route: Route?): Boolean { + return when (route) { + is Route.Home, + is Route.Messages, + is Route.Video, + is Route.Discover, + is Route.Notification -> true + else -> false + } +} +``` + +## Navigation Drawer + +### Material3 ModalDrawerSheet + +```kotlin +@Composable +fun AppDrawer( + drawerState: DrawerState, + nav: Nav, + accountViewModel: AccountViewModel +) { + val scope = rememberCoroutineScope() + + ModalDrawerSheet { + // User profile header + DrawerHeader(accountViewModel.account) + + HorizontalDivider() + + // Menu items + NavigationDrawerItem( + label = { Text("Home") }, + selected = false, + onClick = { nav.nav(Route.Home) }, + icon = { Icon(Icons.Default.Home, "Home") } + ) + + NavigationDrawerItem( + label = { Text("Profile") }, + selected = false, + onClick = { nav.nav(Route.Profile(accountViewModel.account.pubkey)) }, + icon = { Icon(Icons.Default.Person, "Profile") } + ) + + NavigationDrawerItem( + label = { Text("Settings") }, + selected = false, + onClick = { nav.nav(Route.Settings) }, + icon = { Icon(Icons.Default.Settings, "Settings") } + ) + + HorizontalDivider() + + NavigationDrawerItem( + label = { Text("Logout") }, + selected = false, + onClick = { + scope.launch { + accountViewModel.logout() + drawerState.close() + } + }, + icon = { Icon(Icons.Default.Logout, "Logout") } + ) + } +} +``` + +### Main Scaffold with Drawer + +```kotlin +@Composable +fun MainScreen() { + val navController = rememberNavController() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + val nav = remember { Nav(navController, drawerState, scope) } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + AppDrawer(drawerState, nav, accountViewModel) + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Amethyst") }, + navigationIcon = { + IconButton( + onClick = { scope.launch { drawerState.open() } } + ) { + Icon(Icons.Default.Menu, "Menu") + } + } + ) + }, + bottomBar = { AppBottomBar(currentRoute, nav) } + ) { paddingValues -> + AppNavigation( + navController = navController, + modifier = Modifier.padding(paddingValues) + ) + } + } +} +``` + +## Deep Link Handling + +### Intent Processing + +```kotlin +@Composable +fun AppNavigation( + navController: NavHostController, + accountViewModel: AccountViewModel +) { + val activity = LocalContext.current as? Activity + + // Handle incoming intents + LaunchedEffect(activity?.intent) { + activity?.intent?.let { intent -> + handleIntent(intent, navController) + } + } + + NavHost(navController = navController) { + // Routes... + } +} + +fun handleIntent(intent: Intent, navController: NavHostController) { + when (intent.action) { + Intent.ACTION_SEND -> { + // Share text/image + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + val sharedUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + + navController.navigate( + Route.NewPost( + message = sharedText, + attachment = sharedUri?.toString() + ) + ) + } + + Intent.ACTION_VIEW -> { + // Deep link + intent.data?.let { uri -> + when (uri.scheme) { + "nostr" -> handleNostrUri(uri, navController) + "https", "http" -> handleWebUri(uri, navController) + } + } + } + } +} + +fun handleNostrUri(uri: Uri, navController: NavHostController) { + val path = uri.pathSegments.firstOrNull() ?: return + + when { + path.startsWith("npub") -> { + navController.navigate(Route.Profile(path)) + } + path.startsWith("note") -> { + navController.navigate(Route.Note(path)) + } + path.startsWith("nevent") -> { + // Decode and navigate to event + val eventId = decodeNevent(path) + navController.navigate(Route.Note(eventId)) + } + } +} + +fun handleWebUri(uri: Uri, navController: NavHostController) { + // Handle web-based deep links + // https://njump.me/npub1... + // https://primal.net/profile/npub1... + when (uri.host) { + "njump.me" -> { + val id = uri.pathSegments.lastOrNull() + if (id?.startsWith("npub") == true) { + navController.navigate(Route.Profile(id)) + } + } + "primal.net" -> { + // Parse primal.net URLs + } + } +} +``` + +### AndroidManifest Intent Filters + +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` + +## Nested Navigation + +### Tab Navigation Inside Screen + +```kotlin +@Composable +fun ProfileScreen( + pubkey: String, + nav: Nav +) { + val nestedNavController = rememberNavController() + + Column { + ProfileHeader(pubkey) + + // Tab row + TabRow(selectedTabIndex = currentTab) { + Tab(selected = currentTab == 0, onClick = { /* Notes */ }) + Tab(selected = currentTab == 1, onClick = { /* Replies */ }) + Tab(selected = currentTab == 2, onClick = { /* Likes */ }) + } + + // Nested NavHost for tabs + NavHost( + navController = nestedNavController, + startDestination = ProfileTab.Notes + ) { + composable { + NotesTabContent(pubkey) + } + composable { + RepliesTabContent(pubkey) + } + composable { + LikesTabContent(pubkey) + } + } + } +} + +@Serializable +sealed class ProfileTab { + @Serializable object Notes : ProfileTab() + @Serializable object Replies : ProfileTab() + @Serializable object Likes : ProfileTab() +} +``` + +## Testing Navigation + +### Navigation Test Example + +```kotlin +@Test +fun testNavigationToProfile() { + val navController = TestNavHostController( + ApplicationProvider.getApplicationContext() + ) + + composeTestRule.setContent { + navController.navigatorProvider.addNavigator( + ComposeNavigator() + ) + AppNavigation(navController, accountViewModel) + } + + // Navigate to profile + composeTestRule.onNodeWithText("Profile").performClick() + + // Verify navigation + val currentRoute = navController.currentBackStackEntry?.toRoute() + assertTrue(currentRoute is Route.Profile) +} +``` + +## File Locations + +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Nav.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt` diff --git a/.claude/skills/android-expert/references/android-permissions.md b/.claude/skills/android-expert/references/android-permissions.md new file mode 100644 index 000000000..89cc790a1 --- /dev/null +++ b/.claude/skills/android-expert/references/android-permissions.md @@ -0,0 +1,659 @@ +# Android Runtime Permissions + +Complete permission handling patterns for Amethyst using Accompanist Permissions library and Android best practices. + +## Permission Categories in Amethyst + +### Network Permissions (Normal - Auto-granted) + +```xml + + + +``` + +### Media Permissions (Dangerous - Runtime request) + +```xml + + + + + + + + + +``` + +### Notification Permissions (Android 13+) + +```xml + +``` + +### Location Permissions + +```xml + +``` + +### NFC Permissions + +```xml + +``` + +### Foreground Service Permissions + +```xml + + + +``` + +## Accompanist Permissions Library + +### Setup + +```gradle +dependencies { + implementation("com.google.accompanist:accompanist-permissions:0.36.0") +} +``` + +### Single Permission Pattern + +```kotlin +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.shouldShowRationale + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraFeature() { + val cameraPermissionState = rememberPermissionState( + Manifest.permission.CAMERA + ) + + when { + // Permission granted - show feature + cameraPermissionState.status.isGranted -> { + CameraPreview() + } + + // Should show rationale - explain why permission is needed + cameraPermissionState.status.shouldShowRationale -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Camera permission is needed to scan QR codes for login", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { cameraPermissionState.launchPermissionRequest() } + ) { + Text("Grant Permission") + } + } + } + + // First time - request permission + else -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Button( + onClick = { cameraPermissionState.launchPermissionRequest() } + ) { + Icon(Icons.Default.CameraAlt, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Enable Camera") + } + } + } + } +} +``` + +### Multiple Permissions Pattern + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MediaUploadFeature() { + val permissionsState = rememberMultiplePermissionsState( + permissions = buildList { + add(Manifest.permission.CAMERA) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + ) + + when { + // All permissions granted + permissionsState.allPermissionsGranted -> { + MediaUploadUI() + } + + // Some permissions need rationale + permissionsState.shouldShowRationale -> { + RationaleDialog( + title = "Permissions Required", + message = "Camera and storage access are needed to upload photos", + onConfirm = { + permissionsState.launchMultiplePermissionRequest() + }, + onDismiss = { /* Handle dismissal */ } + ) + } + + // Request all permissions + else -> { + PermissionRequestScreen( + permissions = permissionsState.permissions, + onRequestPermissions = { + permissionsState.launchMultiplePermissionRequest() + } + ) + } + } +} + +@Composable +fun RationaleDialog( + title: String, + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Continue") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} +``` + +## Lifecycle-Aware Permission Requests + +### Amethyst Pattern: POST_NOTIFICATIONS + +**File:** `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt` + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun NotificationRegistration(accountViewModel: AccountViewModel) { + val context = LocalContext.current + + // Only request on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermissionState = rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS + ) + + // Register for push notifications when permission is granted + if (notificationPermissionState.status.isGranted) { + LifecycleResumeEffect( + key1 = accountViewModel, + key2 = notificationPermissionState.status.isGranted + ) { + val scope = rememberCoroutineScope() + scope.launch(Dispatchers.IO) { + PushNotificationUtils.checkAndInit( + context = context, + accountViewModel = accountViewModel + ) + } + + onPauseOrDispose { + // Cleanup when composable pauses or disposes + } + } + } else { + // Show prompt to enable notifications + NotificationPermissionPrompt( + onEnableClick = { + notificationPermissionState.launchPermissionRequest() + } + ) + } + } +} + +@Composable +fun NotificationPermissionPrompt(onEnableClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Enable Notifications", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Get notified when someone mentions you or replies to your posts", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onEnableClick, + modifier = Modifier.fillMaxWidth() + ) { + Text("Enable Notifications") + } + } + } +} +``` + +## Permission Best Practices + +### 1. Request Contextually + +**Bad:** +```kotlin +// Requesting permission on app launch +@Composable +fun AppContent() { + val permissionState = rememberPermissionState(Manifest.permission.CAMERA) + + LaunchedEffect(Unit) { + // DON'T DO THIS - user doesn't know why + permissionState.launchPermissionRequest() + } +} +``` + +**Good:** +```kotlin +// Request when user explicitly wants to use camera +@Composable +fun QRScannerButton() { + val permissionState = rememberPermissionState(Manifest.permission.CAMERA) + + Button( + onClick = { + if (permissionState.status.isGranted) { + // Open scanner + } else { + // Request permission + permissionState.launchPermissionRequest() + } + } + ) { + Text("Scan QR Code") + } +} +``` + +### 2. Show Rationale + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun LocationFeature() { + val locationPermissionState = rememberPermissionState( + Manifest.permission.ACCESS_COARSE_LOCATION + ) + + // Always show rationale first for sensitive permissions + if (!locationPermissionState.status.isGranted) { + LocationRationaleCard( + onEnableClick = { + locationPermissionState.launchPermissionRequest() + } + ) + } else { + LocationMap() + } +} + +@Composable +fun LocationRationaleCard(onEnableClick: () -> Unit) { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Why location access?", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Location is used for geohashing your posts. " + + "This helps other users discover local content. " + + "Your exact location is never shared.", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Row { + OutlinedButton(onClick = { /* Skip */ }) { + Text("Skip") + } + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = onEnableClick) { + Text("Enable") + } + } + } + } +} +``` + +### 3. Handle Permanent Denial + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraFeatureWithSettings() { + val context = LocalContext.current + val cameraPermissionState = rememberPermissionState( + Manifest.permission.CAMERA + ) + + when { + cameraPermissionState.status.isGranted -> { + CameraPreview() + } + + cameraPermissionState.status.shouldShowRationale -> { + // User denied once, show rationale + RationaleDialog( + onConfirm = { cameraPermissionState.launchPermissionRequest() } + ) + } + + else -> { + // Might be permanently denied - offer settings + PermanentlyDeniedDialog( + onOpenSettings = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } + ) + } + } +} + +@Composable +fun PermanentlyDeniedDialog(onOpenSettings: () -> Unit) { + AlertDialog( + onDismissRequest = { }, + title = { Text("Permission Denied") }, + text = { + Text( + "Camera permission is required for QR scanning. " + + "Please enable it in Settings." + ) + }, + confirmButton = { + TextButton(onClick = onOpenSettings) { + Text("Open Settings") + } + }, + dismissButton = { + TextButton(onClick = { /* Cancel */ }) { + Text("Cancel") + } + } + ) +} +``` + +### 4. Version-Specific Permissions + +```kotlin +@Composable +fun StoragePermissionRequest() { + val permissions = remember { + buildList { + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + add(Manifest.permission.READ_MEDIA_IMAGES) + add(Manifest.permission.READ_MEDIA_VIDEO) + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { + // Android 10-12: No permission needed for scoped storage + } + else -> { + // Android 9 and below + add(Manifest.permission.READ_EXTERNAL_STORAGE) + add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } + } + } + + if (permissions.isNotEmpty()) { + val permissionsState = rememberMultiplePermissionsState(permissions) + + if (!permissionsState.allPermissionsGranted) { + StoragePermissionUI( + onRequest = { permissionsState.launchMultiplePermissionRequest() } + ) + } else { + MediaPickerUI() + } + } else { + // No permission needed + MediaPickerUI() + } +} +``` + +## Permission Groups + +### Camera + Storage (Media Upload) + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MediaCaptureFeature() { + val mediaPermissions = buildList { + add(Manifest.permission.CAMERA) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add(Manifest.permission.READ_MEDIA_IMAGES) + } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + val permissionsState = rememberMultiplePermissionsState(mediaPermissions) + + when { + permissionsState.allPermissionsGranted -> { + MediaCaptureUI() + } + else -> { + MediaPermissionScreen( + permissions = permissionsState.permissions, + onRequest = { permissionsState.launchMultiplePermissionRequest() } + ) + } + } +} +``` + +### Audio + Storage (Voice Recording) + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun VoiceRecordingFeature() { + val audioPermissions = buildList { + add(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add(Manifest.permission.READ_MEDIA_AUDIO) + } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + val permissionsState = rememberMultiplePermissionsState(audioPermissions) + + if (permissionsState.allPermissionsGranted) { + AudioRecorderUI() + } else { + AudioPermissionScreen( + onRequest = { permissionsState.launchMultiplePermissionRequest() } + ) + } +} +``` + +## Testing Permissions + +### Grant Permission in Tests + +```kotlin +@get:Rule +val permissionRule = GrantPermissionRule.grant( + Manifest.permission.CAMERA, + Manifest.permission.READ_EXTERNAL_STORAGE +) + +@Test +fun testCameraFeatureWithPermission() { + composeTestRule.setContent { + CameraFeature() + } + + // Permission is already granted by rule + composeTestRule.onNodeWithText("Take Photo").assertExists() +} +``` + +### Test Permission Request Flow + +```kotlin +@Test +fun testPermissionRequestFlow() { + composeTestRule.setContent { + CameraFeature() + } + + // Initially shows permission request button + composeTestRule.onNodeWithText("Enable Camera").assertExists() + + // Click to request + composeTestRule.onNodeWithText("Enable Camera").performClick() + + // System permission dialog appears (can't test dialog itself) + // Would need UiAutomator to interact with system dialog +} +``` + +## Permission State Checking + +### Check Permission Before Action + +```kotlin +fun checkAndRequestCameraPermission( + context: Context, + permissionState: PermissionState, + onGranted: () -> Unit +) { + when { + permissionState.status.isGranted -> { + onGranted() + } + permissionState.status.shouldShowRationale -> { + // Show rationale dialog + } + else -> { + permissionState.launchPermissionRequest() + } + } +} +``` + +### Manual Permission Check (Non-Compose) + +```kotlin +fun hasCameraPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED +} + +fun requestCameraPermission(activity: ComponentActivity) { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.CAMERA), + REQUEST_CAMERA_PERMISSION + ) +} + +// In Activity +override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray +) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + REQUEST_CAMERA_PERMISSION -> { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission granted + } else { + // Permission denied + } + } + } +} + +companion object { + private const val REQUEST_CAMERA_PERMISSION = 100 +} +``` + +## File Locations + +- `amethyst/src/main/AndroidManifest.xml` - Permission declarations +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt` - Notification permission pattern +- `amethyst/build.gradle` - Accompanist dependency + +## Resources + +- [Accompanist Permissions Documentation](https://google.github.io/accompanist/permissions/) +- [Android Permissions Guide](https://developer.android.com/guide/topics/permissions/overview) +- [Request Runtime Permissions](https://developer.android.com/training/permissions/requesting) diff --git a/.claude/skills/android-expert/references/proguard-rules.md b/.claude/skills/android-expert/references/proguard-rules.md new file mode 100644 index 000000000..b09f3e1b5 --- /dev/null +++ b/.claude/skills/android-expert/references/proguard-rules.md @@ -0,0 +1,468 @@ +# Proguard Rules for Amethyst + +Proguard configuration for optimizing and obfuscating Android APK while preserving necessary code. + +## What is Proguard/R8? + +**R8** is Android's default code shrinker and obfuscator (replaced Proguard in AGP 3.4.0+). It: +- **Shrinks** code by removing unused classes/methods +- **Obfuscates** code by renaming classes/methods to short names +- **Optimizes** code by inlining methods and removing dead code + +## Amethyst Proguard Configuration + +**File:** `amethyst/proguard-rules.pro` + +### Keep Kotlin Metadata + +```proguard +# Kotlin metadata is required for reflection +-keep class kotlin.Metadata { *; } +-keep class kotlin.** { *; } +-dontwarn kotlin.** + +# Kotlin serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-dontnote kotlinx.serialization.SerializationKt + +-keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; } +-keepclassmembers class com.vitorpamplona.** { + *** Companion; +} +-keepclasseswithmembers class com.vitorpamplona.** { + kotlinx.serialization.KSerializer serializer(...); +} +``` + +### Keep Nostr Event Classes + +```proguard +# Nostr events are serialized/deserialized +-keep class com.vitorpamplona.quartz.events.** { *; } +-keep class com.vitorpamplona.quartz.encoders.** { *; } + +# Keep event builders +-keep class com.vitorpamplona.quartz.builders.** { *; } + +# Keep tag classes +-keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; } +``` + +### Keep Data Classes + +```proguard +# Data classes used in ViewModels and serialization +-keep @kotlinx.serialization.Serializable class * { *; } + +# Keep all data classes +-keep class com.vitorpamplona.amethyst.model.** { *; } +-keep class com.vitorpamplona.amethyst.service.model.** { *; } +``` + +### Keep Compose Classes + +```proguard +# Jetpack Compose +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** + +# Compose runtime +-keep class androidx.compose.runtime.** { *; } + +# Compose UI +-keep class androidx.compose.ui.** { *; } + +# Material3 +-keep class androidx.compose.material3.** { *; } + +# Navigation Compose - Keep serializable routes +-keep class * implements java.io.Serializable { *; } +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} +``` + +### Keep OkHttp/Retrofit + +```proguard +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } +-keep class okio.** { *; } + +# OkHttp WebSockets (for Nostr relays) +-keep class okhttp3.internal.ws.** { *; } + +# Retrofit (if used) +-keepattributes Signature +-keepattributes Exceptions +-keep class retrofit2.** { *; } +``` + +### Keep Jackson (JSON) + +```proguard +# Jackson JSON library +-keep class com.fasterxml.jackson.** { *; } +-keep class org.codehaus.** { *; } +-keepclassmembers class * { + @com.fasterxml.jackson.annotation.* ; +} + +# Jackson polymorphic types +-keepattributes RuntimeVisibleAnnotations +-keep @com.fasterxml.jackson.annotation.JsonTypeInfo class * +``` + +### Keep Secp256k1 (Crypto) + +```proguard +# Secp256k1 native library +-keep class fr.acinq.secp256k1.** { *; } + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} +``` + +### Keep Tor + +```proguard +# Tor library +-keep class com.msopentech.thali.toronionproxy.** { *; } +-dontwarn com.msopentech.thali.toronionproxy.** +``` + +### Keep ExoPlayer (Media) + +```proguard +# ExoPlayer (Media3) +-keep class androidx.media3.** { *; } +-dontwarn androidx.media3.** + +-keep class com.google.android.exoplayer2.** { *; } +-dontwarn com.google.android.exoplayer2.** +``` + +### Keep Coil (Image Loading) + +```proguard +# Coil image loading +-keep class coil.** { *; } +-keep class coil3.** { *; } +-dontwarn coil.** +-dontwarn coil3.** +``` + +### Keep ViewModels + +```proguard +# ViewModel classes +-keep class * extends androidx.lifecycle.ViewModel { + (); +} + +# ViewModel factories +-keep class * extends androidx.lifecycle.ViewModelProvider$Factory { + (...); +} + +# Keep ViewModel constructors for reflection +-keepclassmembers class * extends androidx.lifecycle.ViewModel { + (...); +} +``` + +### Keep Parcelable + +```proguard +# Parcelable +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} + +-keepclassmembers class * implements android.os.Parcelable { + public ; + private ; +} +``` + +### Keep Enums + +```proguard +# Enums +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} +``` + +### Remove Logging (Production) + +```proguard +# Remove debug logging in release builds +-assumenosideeffects class android.util.Log { + public static *** d(...); + public static *** v(...); + public static *** i(...); +} + +# Keep error/warning logs +-assumenosideeffects class android.util.Log { + public static *** e(...) return false; + public static *** w(...) return false; +} +``` + +### Keep Crashlytics/Firebase + +```proguard +# Firebase Crashlytics +-keepattributes SourceFile,LineNumberTable +-keep public class * extends java.lang.Exception + +# Firebase +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** +``` + +## Build Configuration + +### Enable R8 in build.gradle + +```gradle +android { + buildTypes { + release { + minifyEnabled = true + shrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + + debug { + minifyEnabled = false + } + } +} +``` + +### Multiple Proguard Files + +```gradle +android { + buildTypes { + release { + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + "proguard-quartz.pro", // Library-specific rules + "proguard-compose.pro" // Compose-specific rules + ) + } + } +} +``` + +## Debugging Proguard Issues + +### Generate Mapping File + +R8 generates `mapping.txt` in `app/build/outputs/mapping/release/`: + +``` +# Original class name -> Obfuscated name +com.vitorpamplona.amethyst.ui.MainActivity -> a.b.c: + void onCreate(Bundle) -> a +``` + +### Deobfuscate Stack Traces + +```bash +# Using retrace (part of Android SDK) +retrace.sh mapping.txt stacktrace.txt +``` + +### Enable Proguard Output + +```gradle +android { + buildTypes { + release { + proguardFiles(...) + + // Generate reports + postprocessing { + proguardFiles = [...] + obfuscate = true + optimizeCode = true + removeUnusedCode = true + } + } + } +} +``` + +**Output files:** +- `build/outputs/mapping/release/configuration.txt` - All Proguard rules applied +- `build/outputs/mapping/release/mapping.txt` - Obfuscation mappings +- `build/outputs/mapping/release/seeds.txt` - Classes kept by `-keep` rules +- `build/outputs/mapping/release/usage.txt` - Code removed by R8 + +### Test Release Build + +```bash +./gradlew assembleRelease + +# Install and test +adb install app/build/outputs/apk/release/app-release.apk +``` + +## Common Issues + +### Issue: NoSuchMethodException at Runtime + +**Cause:** Proguard removed or renamed a method used via reflection. + +**Solution:** +```proguard +-keep class com.example.YourClass { + public ; +} +``` + +### Issue: Serialization Fails + +**Cause:** Data class fields were renamed. + +**Solution:** +```proguard +-keep @kotlinx.serialization.Serializable class * { *; } +-keepclassmembers class * { + @kotlinx.serialization.SerialName ; +} +``` + +### Issue: Compose Navigation Crashes + +**Cause:** @Serializable route classes were obfuscated. + +**Solution:** +```proguard +# Keep all route classes +-keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; } +``` + +### Issue: Native Library Crashes + +**Cause:** Native method signatures were changed. + +**Solution:** +```proguard +-keepclasseswithmembernames class * { + native ; +} +``` + +## Optimization Tips + +### 1. Keep Only What's Necessary + +Don't use broad wildcards: +```proguard +# Bad - keeps everything +-keep class com.vitorpamplona.** { *; } + +# Good - keeps only specific packages +-keep class com.vitorpamplona.quartz.events.** { *; } +``` + +### 2. Test Thoroughly + +- Test all app features after enabling Proguard +- Test deep links and navigation +- Test serialization/deserialization +- Test external library integrations + +### 3. Use AGP's Proguard Analysis + +```gradle +android { + buildTypes { + release { + // Generate R8 configuration + android.debug.obsoleteApi = true + } + } +} +``` + +### 4. Analyze APK Size + +```bash +# Build release APK +./gradlew assembleRelease + +# Analyze APK with Android Studio +# Build > Analyze APK > Select app-release.apk +``` + +See `scripts/analyze-apk-size.sh` for automated analysis. + +## Product Flavor Specific Rules + +### Play Flavor (Firebase) + +```proguard +# proguard-play.pro +-keep class com.google.firebase.** { *; } +-keep class com.google.android.gms.** { *; } +``` + +### F-Droid Flavor (No Google Services) + +```proguard +# proguard-fdroid.pro +# UnifiedPush +-keep class org.unifiedpush.** { *; } +``` + +**Configure in build.gradle:** +```gradle +android { + flavorDimensions = ["channel"] + productFlavors { + create("play") { + dimension = "channel" + proguardFiles("proguard-play.pro") + } + create("fdroid") { + dimension = "channel" + proguardFiles("proguard-fdroid.pro") + } + } +} +``` + +## File Locations + +- `amethyst/proguard-rules.pro` - Main Proguard rules +- `amethyst/build/outputs/mapping/release/` - Proguard output files +- `amethyst/build.gradle` - Proguard configuration + +## Resources + +- [Android R8 Documentation](https://developer.android.com/build/shrink-code) +- [Proguard Manual](https://www.guardsquare.com/manual/configuration) +- [Kotlinx Serialization Proguard](https://github.com/Kotlin/kotlinx.serialization#android) diff --git a/.claude/skills/android-expert/scripts/analyze-apk-size.sh b/.claude/skills/android-expert/scripts/analyze-apk-size.sh new file mode 100755 index 000000000..023483130 --- /dev/null +++ b/.claude/skills/android-expert/scripts/analyze-apk-size.sh @@ -0,0 +1,230 @@ +#!/bin/bash +# +# APK Size Analysis Script for Amethyst +# +# Usage: +# ./analyze-apk-size.sh [apk-path] +# +# If no APK path provided, uses latest release build + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default APK path +DEFAULT_APK="amethyst/build/outputs/apk/release/amethyst-release.apk" +APK_PATH="${1:-$DEFAULT_APK}" + +# Check if APK exists +if [ ! -f "$APK_PATH" ]; then + echo -e "${RED}Error: APK not found at $APK_PATH${NC}" + echo "Build the APK first: ./gradlew :amethyst:assembleRelease" + exit 1 +fi + +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Amethyst APK Size Analysis${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo + +# APK basic info +echo -e "${GREEN}APK Path:${NC} $APK_PATH" +APK_SIZE=$(du -h "$APK_PATH" | cut -f1) +APK_SIZE_BYTES=$(stat -f%z "$APK_PATH" 2>/dev/null || stat -c%s "$APK_PATH" 2>/dev/null) +echo -e "${GREEN}APK Size:${NC} $APK_SIZE ($APK_SIZE_BYTES bytes)" +echo + +# Extract APK to temp directory +TEMP_DIR=$(mktemp -d) +echo -e "${YELLOW}Extracting APK to $TEMP_DIR...${NC}" +unzip -q "$APK_PATH" -d "$TEMP_DIR" + +# Analyze APK contents +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Top-Level Contents${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +du -sh "$TEMP_DIR"/* | sort -hr + +# Analyze DEX files +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} DEX Files (Code)${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +DEX_TOTAL=0 +for dex in "$TEMP_DIR"/*.dex; do + if [ -f "$dex" ]; then + DEX_NAME=$(basename "$dex") + DEX_SIZE=$(stat -f%z "$dex" 2>/dev/null || stat -c%s "$dex" 2>/dev/null) + DEX_SIZE_MB=$(echo "scale=2; $DEX_SIZE / 1024 / 1024" | bc) + echo -e " $DEX_NAME: ${GREEN}${DEX_SIZE_MB} MB${NC}" + DEX_TOTAL=$((DEX_TOTAL + DEX_SIZE)) + fi +done +DEX_TOTAL_MB=$(echo "scale=2; $DEX_TOTAL / 1024 / 1024" | bc) +echo -e "${YELLOW}Total DEX: $DEX_TOTAL_MB MB${NC}" + +# Analyze resources +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Resources${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +if [ -d "$TEMP_DIR/res" ]; then + RES_SIZE=$(du -sh "$TEMP_DIR/res" | cut -f1) + echo -e " res/: ${GREEN}$RES_SIZE${NC}" + + # Top resource folders + echo " Top resource folders:" + du -sh "$TEMP_DIR/res"/* | sort -hr | head -10 | sed 's/^/ /' +fi + +# Analyze assets +echo +if [ -d "$TEMP_DIR/assets" ]; then + ASSETS_SIZE=$(du -sh "$TEMP_DIR/assets" | cut -f1) + echo -e " assets/: ${GREEN}$ASSETS_SIZE${NC}" + + # Top asset files + echo " Top asset files:" + find "$TEMP_DIR/assets" -type f -exec du -h {} \; | sort -hr | head -10 | sed 's/^/ /' +fi + +# Analyze native libraries +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Native Libraries${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +if [ -d "$TEMP_DIR/lib" ]; then + LIB_SIZE=$(du -sh "$TEMP_DIR/lib" | cut -f1) + echo -e " lib/: ${GREEN}$LIB_SIZE${NC}" + + # By architecture + for arch in "$TEMP_DIR/lib"/*; do + if [ -d "$arch" ]; then + ARCH_NAME=$(basename "$arch") + ARCH_SIZE=$(du -sh "$arch" | cut -f1) + echo -e " $ARCH_NAME: ${GREEN}$ARCH_SIZE${NC}" + + # Top libraries in architecture + find "$arch" -type f -name "*.so" -exec du -h {} \; | sort -hr | head -5 | sed 's/^/ /' + fi + done +else + echo " No native libraries" +fi + +# Analyze Kotlin metadata +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Kotlin Metadata${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +if [ -d "$TEMP_DIR/kotlin" ]; then + KOTLIN_SIZE=$(du -sh "$TEMP_DIR/kotlin" | cut -f1) + echo -e " kotlin/: ${GREEN}$KOTLIN_SIZE${NC}" +fi + +# Method count (if dexdump available) +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Method Count${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" + +# Try to find dexdump +DEXDUMP=$(which dexdump 2>/dev/null || find "$ANDROID_HOME/build-tools" -name dexdump 2>/dev/null | head -1 || echo "") + +if [ -n "$DEXDUMP" ] && [ -x "$DEXDUMP" ]; then + TOTAL_METHODS=0 + for dex in "$TEMP_DIR"/*.dex; do + if [ -f "$dex" ]; then + DEX_NAME=$(basename "$dex") + METHOD_COUNT=$("$DEXDUMP" -l xml "$dex" | grep -c "/dev/null | awk '{sum+=$1} END {print sum}' || echo "0") +RES_SIZE_BYTES=$(du -s "$TEMP_DIR/res" 2>/dev/null | awk '{print $1}' || echo "0") +ASSETS_SIZE_BYTES=$(du -s "$TEMP_DIR/assets" 2>/dev/null | awk '{print $1}' || echo "0") +LIB_SIZE_BYTES=$(du -s "$TEMP_DIR/lib" 2>/dev/null | awk '{print $1}' || echo "0") + +# Convert to KB for bc +APK_SIZE_KB=$((APK_SIZE_BYTES / 1024)) +CODE_PCT=$(echo "scale=1; $CODE_SIZE * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0") +RES_PCT=$(echo "scale=1; $RES_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0") +ASSETS_PCT=$(echo "scale=1; $ASSETS_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0") +LIB_PCT=$(echo "scale=1; $LIB_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0") + +echo -e " Code (DEX): ${CODE_PCT}%" +echo -e " Resources: ${RES_PCT}%" +echo -e " Assets: ${ASSETS_PCT}%" +echo -e " Native libs: ${LIB_PCT}%" + +# Recommendations +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Optimization Recommendations${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" + +# Check if resources are large +if [ $(echo "$RES_PCT > 30" | bc 2>/dev/null || echo "0") -eq 1 ]; then + echo -e "${YELLOW} • Resources are ${RES_PCT}% of APK - consider:${NC}" + echo " - Enable resource shrinking (shrinkResources = true)" + echo " - Use WebP for images instead of PNG/JPG" + echo " - Remove unused resources" +fi + +# Check if native libs are large +if [ $(echo "$LIB_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then + echo -e "${YELLOW} • Native libraries are ${LIB_PCT}% of APK - consider:${NC}" + echo " - Use App Bundle to serve ABI-specific APKs" + echo " - Remove unused ABIs" +fi + +# Check if code is large +if [ $(echo "$CODE_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then + echo -e "${YELLOW} • Code is ${CODE_PCT}% of APK - consider:${NC}" + echo " - Enable Proguard/R8 (minifyEnabled = true)" + echo " - Review dependencies for bloat" + echo " - Enable code shrinking" +fi + +# General recommendations +echo -e "${GREEN} General optimizations:${NC}" +echo " - Use Android App Bundle (.aab) instead of APK" +echo " - Enable R8 optimization (minifyEnabled = true)" +echo " - Enable resource shrinking (shrinkResources = true)" +echo " - Analyze with APK Analyzer in Android Studio" + +# Cleanup +echo +echo -e "${YELLOW}Cleaning up temporary files...${NC}" +rm -rf "$TEMP_DIR" + +echo +echo -e "${GREEN}✓ Analysis complete${NC}" diff --git a/.claude/skills/compose-expert/SKILL.md b/.claude/skills/compose-expert/SKILL.md new file mode 100644 index 000000000..0ba02c013 --- /dev/null +++ b/.claude/skills/compose-expert/SKILL.md @@ -0,0 +1,577 @@ +--- +name: compose-expert +description: Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector icons, or determining whether to share UI in commonMain vs keep platform-specific. Delegates navigation to android-expert/desktop-expert. Complements kotlin-expert (handles Kotlin language aspects of state/annotations). +--- + +# Compose Multiplatform Expert + +Visual UI patterns for sharing composables across Android and Desktop. + +## When to Use This Skill + +- Creating or refactoring shared UI components +- Deciding whether to share UI in `commonMain` or keep platform-specific +- Building custom ImageVector icons (robohash pattern) +- State management: remember, derivedStateOf, produceState +- Recomposition optimization: visual usage of @Stable/@Immutable +- Material3 theming and styling +- Performance: lazy lists, image loading + +**Delegate to other skills:** +- Navigation structure → `android-expert`, `desktop-expert` +- Kotlin state patterns (StateFlow, sealed classes) → `kotlin-expert` +- Build configuration → `gradle-expert` + +## Philosophy: Share by Default + +**Default to `commons/commonMain`** unless platform experts indicate otherwise. + +### Always Share + +- **UI components**: Buttons, cards, lists, dialogs, inputs +- **State visualization**: Loading, empty, error states +- **Custom icons**: ImageVector assets (robohash, custom paths) +- **Theme utilities**: Color calculations, style helpers +- **Material3 components**: Any UI using Material primitives + +### Keep Platform-Specific + +- **Navigation structure**: Bottom nav (Android) vs Sidebar (Desktop) +- **Screen layouts**: Platform-specific scaffolding +- **System integrations**: File pickers, notifications, share sheets +- **Platform UX**: Gestures, keyboard shortcuts, window management + +### Decision Framework + +1. **Uses only Material3 primitives?** → Share in `commonMain` +2. **Requires platform system APIs?** → Platform-specific +3. **Pure visual component without navigation?** → Share in `commonMain` +4. **Needs platform UX patterns?** → Ask `android-expert` or `desktop-expert` + +If uncertain, **default to sharing** - easier to split later than merge. + +## Shared Composable Anatomy + +### Structure + +```kotlin +@Composable +fun SharedComponent( + // State parameters (read-only) + data: DataClass, + isLoading: Boolean, + // Event parameters (write-only) + onAction: () -> Unit, + // Visual parameters + modifier: Modifier = Modifier, + // Optional customization + colors: ComponentColors = ComponentDefaults.colors() +) { + // Implementation +} +``` + +**Pattern**: State down, events up +- Parameters above modifier = required state/events +- `modifier` parameter = layout control +- Parameters below modifier = optional customization + +### Example: AddButton + +```kotlin +@Composable +fun AddButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Add", + enabled: Boolean = true +) { + OutlinedButton( + modifier = modifier, + enabled = enabled, + onClick = onClick, + shape = ActionButtonShape, + contentPadding = ActionButtonPadding + ) { + Text(text = text, textAlign = TextAlign.Center) + } +} + +// Shared constants for consistency +val ActionButtonShape = RoundedCornerShape(20.dp) +val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp) +``` + +**Why this works on all platforms:** +- Material3 primitives (OutlinedButton, Text) +- No platform APIs +- Configurable through parameters +- Consistent styling via shared constants + +## State Management Patterns + +### remember - Cache Across Recompositions + +```kotlin +@Composable +fun ExpandableCard() { + var isExpanded by remember { mutableStateOf(false) } + + Column { + IconButton(onClick = { isExpanded = !isExpanded }) { + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand" + ) + } + + if (isExpanded) { + Text("Expanded content...") + } + } +} +``` + +**Visual pattern**: Toggle button → state changes → UI expands/collapses +**Use for**: Simple UI state (toggles, counters, text input) + +### derivedStateOf - Optimize Frequent Changes + +```kotlin +@Composable +fun ScrollToTopButton(listState: LazyListState) { + // Only recomposes when showButton changes, not every scroll pixel + val showButton by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 + } + } + + if (showButton) { + FloatingActionButton(onClick = { /* scroll to top */ }) { + Icon(Icons.Default.ArrowUpward, null) + } + } +} +``` + +**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → Button visibility +**Use for**: Input changes frequently, derived result changes rarely +**Performance**: Prevents recomposition on every scroll event + +### produceState - Async to Compose State + +```kotlin +@Composable +fun LoadUserProfile(userId: String): State { + return produceState(initialValue = null, userId) { + value = repository.fetchUser(userId) + } +} + +@Composable +fun ProfileScreen(userId: String) { + val user by LoadUserProfile(userId) + + when (user) { + null -> LoadingState("Loading profile...") + else -> ProfileCard(user!!) + } +} +``` + +**Visual pattern**: Async operation → state updates → UI reflects changes +**Use for**: Convert Flow, LiveData, callbacks into Compose state +**Lifecycle**: Coroutine cancelled when composable leaves composition + +For Kotlin-specific state patterns (StateFlow, sealed classes), see `kotlin-expert`. + +## State Hoisting + +Move state up to make composables reusable: + +```kotlin +// ❌ Stateful - hard to test, can't control externally +@Composable +fun BadSearchBar() { + var query by remember { mutableStateOf("") } + TextField(value = query, onValueChange = { query = it }) +} + +// ✅ Stateless - reusable, testable +@Composable +fun GoodSearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier + ) +} + +@Composable +fun SearchScreen() { + var query by remember { mutableStateOf("") } + + Column { + GoodSearchBar(query = query, onQueryChange = { query = it }) + SearchResults(query = query) + } +} +``` + +**Principle**: State up, events down +- State: `query: String` (read-only parameter) +- Events: `onQueryChange: (String) -> Unit` (callback parameter) + +## Recomposition Optimization + +### Visual Usage of @Immutable + +Use @Immutable on data classes passed to composables: + +```kotlin +@Immutable +data class UserProfile(val name: String, val avatar: String) + +@Composable +fun ProfileCard(profile: UserProfile) { + // Only recomposes when profile instance changes + Row { + RobohashImage(robot = profile.avatar) + Text(profile.name, style = MaterialTheme.typography.titleMedium) + } +} +``` + +**Visual effect**: Prevents recomposition when parent recomposes with same data +**Pattern**: Mark parameter data classes as @Immutable +**Note**: For Kotlin language details on @Immutable, see `kotlin-expert` + +### Stable Parameters + +```kotlin +// ✅ Stable - won't trigger recomposition unless colors instance changes +@Composable +fun ThemedCard( + content: String, + colors: CardColors = CardDefaults.colors(), + modifier: Modifier = Modifier +) { + Card(colors = colors, modifier = modifier) { + Text(content) + } +} +``` + +For @Stable annotation details, see `kotlin-expert`. + +## Material3 Theming + +All shared composables use Material3 for consistency: + +```kotlin +@Composable +fun ThemedComponent() { + val bg = MaterialTheme.colorScheme.background + val fg = MaterialTheme.colorScheme.onBackground + val primary = MaterialTheme.colorScheme.primary + + Column( + modifier = Modifier.background(bg) + ) { + Text( + "Title", + style = MaterialTheme.typography.headlineMedium, + color = fg + ) + Button( + onClick = { /* ... */ }, + colors = ButtonDefaults.buttonColors(containerColor = primary) + ) { + Text("Action") + } + } +} +``` + +**Principles:** +- Colors: `MaterialTheme.colorScheme.*` +- Typography: `MaterialTheme.typography.*` +- Shapes: `MaterialTheme.shapes.*` + +### Theme Detection + +```kotlin +@Composable +private fun isLightTheme(): Boolean { + val background = MaterialTheme.colorScheme.background + return (background.red + background.green + background.blue) / 3 > 0.5f +} + +@Composable +fun ThemedIcon() { + val isDark = !isLightTheme() + val tint = if (isDark) Color.White else Color.Black + Icon(Icons.Default.Face, null, tint = tint) +} +``` + +## Custom Icons: ImageVector Pattern + +Amethyst uses ImageVector for multiplatform icons. + +### roboBuilder DSL + +```kotlin +fun roboBuilder(block: Builder.() -> Unit): ImageVector { + return ImageVector.Builder( + name = "Robohash", + defaultWidth = 300.dp, + defaultHeight = 300.dp, + viewportWidth = 300f, + viewportHeight = 300f + ).apply(block).build() +} +``` + +### Building Icons + +```kotlin +fun customIcon(fgColor: SolidColor, builder: Builder) { + builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f) + builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f) + builder.addPath(pathData3, fill = Black, fillAlpha = 0.2f) +} + +private val pathData1 = PathData { + moveTo(144.5f, 87.5f) + reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f) + lineToRelative(16.0f, 16.0f) + close() +} + +@Composable +fun CustomIcon() { + Image( + painter = rememberVectorPainter( + roboBuilder { + customIcon(SolidColor(Color.Blue), this) + } + ), + contentDescription = "Custom icon" + ) +} +``` + +**Why ImageVector?** +- Pure Kotlin, no XML +- Works on Android, Desktop, iOS +- GPU-accelerated +- Type-safe + +### Caching Pattern + +```kotlin +object CustomIcons { + private val cache = mutableMapOf() + + fun get(key: String): ImageVector { + return cache.getOrPut(key) { + buildIcon(key) + } + } +} + +@Composable +fun CachedIcon(key: String) { + Image(imageVector = CustomIcons.get(key), contentDescription = null) +} +``` + +For detailed icon patterns, see `references/icon-assets.md`. + +## Common Visual Patterns + +### State Visualization + +```kotlin +@Composable +fun DataScreen(uiState: UiState) { + when (uiState) { + is UiState.Loading -> LoadingState("Loading...") + is UiState.Empty -> EmptyState( + title = "No data", + onRefresh = { /* refresh */ } + ) + is UiState.Error -> ErrorState( + message = uiState.message, + onRetry = { /* retry */ } + ) + is UiState.Success -> ContentList(uiState.items) + } +} +``` + +**Components** (all in `commons/commonMain`): +- `LoadingState` - Progress indicator + message +- `EmptyState` - Empty message + optional refresh button +- `ErrorState` - Error message + optional retry button + +### Relay Status (Amethyst Pattern) + +```kotlin +@Composable +fun RelayStatusIndicator(connectedCount: Int) { + val statusColor = when { + connectedCount == 0 -> RelayStatusColors.Disconnected + connectedCount < 3 -> RelayStatusColors.Connecting + else -> RelayStatusColors.Connected + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close, + tint = statusColor, + modifier = Modifier.size(16.dp) + ) + Text( + "$connectedCount relay${if (connectedCount != 1) "s" else ""}", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} +``` + +**Visual mapping**: +- 0 relays → Red + X icon +- 1-2 relays → Yellow + Check icon +- 3+ relays → Green + Check icon + +### Placeholder Pattern + +```kotlin +@Composable +fun PlaceholderScreen( + title: String, + description: String, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text(title, style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(16.dp)) + Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +// Specific implementations +@Composable +fun SearchPlaceholder() = PlaceholderScreen( + title = "Search", + description = "Search for users, notes, and hashtags." +) +``` + +**Pattern**: Generic composable + specific wrappers with preset text + +## Performance + +### Avoid Unnecessary Recomposition + +```kotlin +// ❌ Bad - recomposes on every scroll +@Composable +fun BadButton(scrollState: ScrollState) { + if (scrollState.value > 100) { + Button(onClick = {}) { Text("Top") } + } +} + +// ✅ Good - only recomposes when visibility changes +@Composable +fun GoodButton(scrollState: ScrollState) { + val show by remember { derivedStateOf { scrollState.value > 100 } } + if (show) { + Button(onClick = {}) { Text("Top") } + } +} +``` + +### Lazy Lists + +```kotlin +@Composable +fun FeedList(items: List) { + LazyColumn { + items(items, key = { it.id }) { item -> + FeedItem(item) + } + } +} +``` + +**Key principle**: Use `key` parameter for stable item identity + +## Bundled Resources + +- **references/shared-composables-catalog.md** - Complete catalog of shared UI components +- **references/state-patterns.md** - State management patterns with visual examples +- **references/icon-assets.md** - Custom ImageVector icon patterns +- **scripts/find-composables.sh** - Find all @Composable functions in codebase + +## Quick Reference + +| Task | Pattern | Location | +|------|---------|----------| +| Reusable UI | State hoisting | commons/commonMain | +| Simple state | remember { mutableStateOf() } | Composable scope | +| Derived state | derivedStateOf { } | remember block | +| Async → state | produceState { } | Composable function | +| Custom icons | roboBuilder + PathData | commons/icons | +| Loading/Error | LoadingState, ErrorState | commons/ui/components | +| Theme colors | MaterialTheme.colorScheme | Any @Composable | +| Navigation | Delegate to platform expert | amethyst/, desktopApp/ | + +## Common Workflows + +### Creating a Shared Component + +1. Start in `commons/src/commonMain/kotlin/.../ui/components/` +2. Use Material3 primitives only +3. Hoist state (parameters for data, callbacks for events) +4. Add modifier parameter +5. Use MaterialTheme for colors/typography +6. Test on both Android and Desktop + +### Converting Existing Component + +1. Read current implementation in `amethyst/` or `desktopApp/` +2. Identify pure visual logic (no platform APIs) +3. Create in `commons/commonMain` with hoisted state +4. Replace platform implementations with shared component +5. Keep platform-specific wrappers if needed + +### Custom Icon + +1. Export SVG from design tool +2. Convert to PathData using Android Studio +3. Create icon function with roboBuilder +4. Add caching if generated dynamically +5. Wrap in @Composable for easy use + +### Navigation (Delegate) + +For navigation patterns: +- Android bottom nav → `android-expert` +- Desktop sidebar → `desktop-expert` +- Multi-window → `desktop-expert` + +## Related Skills + +- **kotlin-expert** - Kotlin language aspects (@Immutable details, StateFlow, sealed classes) +- **android-expert** - Android navigation, platform APIs +- **desktop-expert** - Desktop navigation, window management, OS specifics +- **kotlin-coroutines** - Async patterns, Flow integration diff --git a/.claude/skills/compose-expert/references/icon-assets.md b/.claude/skills/compose-expert/references/icon-assets.md new file mode 100644 index 000000000..96b8fea26 --- /dev/null +++ b/.claude/skills/compose-expert/references/icon-assets.md @@ -0,0 +1,365 @@ +# Custom Icon Assets and ImageVector Patterns + +Guide to creating and using custom ImageVector icons in Compose Multiplatform. + +## Why ImageVector? + +ImageVector is the native Compose format for vector graphics: +- **Pure Kotlin**: No XML, no asset files +- **Multiplatform**: Works on Android, Desktop, iOS without conversion +- **Performant**: Lightweight, composable, GPU-accelerated +- **Type-safe**: Compile-time checking, no resource IDs + +## Amethyst Pattern: Robohash + +Amethyst generates deterministic avatars using ImageVector builders. + +### Architecture + +``` +commons/robohash/ +├── RobohashAssembler.kt # Main assembly logic +├── CachedRobohash.kt # Caching layer +└── parts/ + ├── Face0C3po.kt # Face variants (0-9) + ├── Eyes2Single.kt # Eye variants (0-9) + ├── Mouth3Grid.kt # Mouth variants (0-9) + ├── Body2Thinnest.kt # Body variants (0-9) + └── Accessory7Antenna.kt # Accessory variants (0-9) +``` + +**Pattern**: 10 variants per feature × 5 features = 100,000+ unique combinations + +### roboBuilder DSL + +Custom ImageVector builder with sensible defaults: + +```kotlin +fun roboBuilder(block: Builder.() -> Unit): ImageVector { + return ImageVector.Builder( + name = "Robohash", + defaultWidth = 300.dp, + defaultHeight = 300.dp, + viewportWidth = 300f, + viewportHeight = 300f + ).apply(block).build() +} +``` + +**Usage**: +```kotlin +@Composable +fun CustomIcon() { + Image( + painter = rememberVectorPainter( + roboBuilder { + // Add paths here + } + ), + contentDescription = "Custom icon" + ) +} +``` + +### Path Building Pattern + +```kotlin +fun face0C3po(fgColor: SolidColor, builder: Builder) { + builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f) + builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f) + builder.addPath(pathData5, fill = Black, fillAlpha = 0.2f) + builder.addPath(pathData6, stroke = Black, strokeLineWidth = 1.0f) + builder.addPath(pathData7, fill = Black, stroke = Black, fillAlpha = 0.2f, strokeLineWidth = 0.75f) +} + +private val pathData1 = PathData { + moveTo(144.5f, 87.5f) + reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f) + curveToRelative(0.0f, 0.0f, 0.0f, 27.0f, 5.0f, 42.0f) + reflectiveCurveToRelative(10.0f, 38.0f, 10.0f, 38.0f) + lineToRelative(16.0f, 16.0f) + // ... + close() +} +``` + +**Key elements**: +- `pathData` variables for path commands +- `addPath()` for each layer +- Parameterized colors (`fgColor`) +- Constant colors (`Black`) +- Alpha for shadows/highlights + +### PathData DSL + +Compose's PathData builder provides SVG-like commands: + +| Command | Description | Example | +|---------|-------------|---------| +| `moveTo(x, y)` | Move pen without drawing | `moveTo(100f, 100f)` | +| `lineTo(x, y)` | Draw line to point | `lineTo(200f, 150f)` | +| `curveToRelative(...)` | Relative cubic Bézier | `curveToRelative(10f, 20f, 30f, 40f, 50f, 60f)` | +| `reflectiveCurveToRelative(...)` | Smooth curve | `reflectiveCurveToRelative(-51f, 3f, -53f, 55f)` | +| `horizontalLineTo(x)` | Horizontal line | `horizontalLineTo(250f)` | +| `verticalLineTo(y)` | Vertical line | `verticalLineTo(300f)` | +| `close()` | Close path | `close()` | + +**Relative vs Absolute**: +- `moveTo` / `lineTo` - Absolute coordinates +- `moveToRelative` / `lineToRelative` - Relative to current position + +## Creating Custom Icons + +### Method 1: From SVG (Recommended) + +1. **Export SVG** from design tool (Figma, Illustrator) +2. **Convert to ImageVector** using Android Studio's Vector Asset tool +3. **Extract path data** and adapt to roboBuilder pattern + +```kotlin +// SVG path: M 10 10 L 20 20 ... +// Becomes: +private val myIconPath = PathData { + moveTo(10f, 10f) + lineTo(20f, 20f) + // ... +} +``` + +### Method 2: Programmatic + +Build paths programmatically for simple shapes: + +```kotlin +fun simpleIcon(): ImageVector = roboBuilder { + addPath( + pathData = PathData { + moveTo(50f, 50f) + lineTo(150f, 50f) + lineTo(150f, 150f) + lineTo(50f, 150f) + close() + }, + fill = SolidColor(Color.Blue), + stroke = SolidColor(Color.Black), + strokeLineWidth = 2f + ) +} +``` + +### Method 3: Material Icons Extensions + +Extend Material Icons when you need platform-consistent icons: + +```kotlin +// For standard icons, use Material Icons +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* + +Icon(Icons.Default.Refresh, contentDescription = "Refresh") +Icon(Icons.Default.Check, contentDescription = "Success") +Icon(Icons.Default.Close, contentDescription = "Error") +``` + +## CachedRobohash Pattern + +Performance optimization for generated icons: + +```kotlin +object CachedRobohash { + private val cache = mutableMapOf, ImageVector>() + + fun get(seed: String, isLight: Boolean): ImageVector { + return cache.getOrPut(seed to isLight) { + RobohashAssembler.assemble(seed, isLight) + } + } +} +``` + +**Pattern**: +- Key: `(seed, theme)` pair +- Value: Assembled ImageVector +- Lifecycle: Application lifetime (never cleared) + +**Usage**: +```kotlin +@Composable +fun RobohashImage(robot: String) { + Image( + imageVector = CachedRobohash.get(robot, isLightTheme()), + contentDescription = "Avatar for $robot" + ) +} +``` + +## Color Management + +### Dynamic Colors + +Pass colors as parameters for theme adaptation: + +```kotlin +fun themedIcon(fgColor: SolidColor, bgColor: SolidColor, builder: Builder) { + builder.addPath(pathData1, fill = bgColor) + builder.addPath(pathData2, fill = fgColor) +} + +@Composable +fun ThemedIcon() { + val fg = MaterialTheme.colorScheme.primary + val bg = MaterialTheme.colorScheme.surface + + Image( + painter = rememberVectorPainter( + roboBuilder { + themedIcon(SolidColor(fg), SolidColor(bg), this) + } + ), + contentDescription = null + ) +} +``` + +### Static Colors + +Define constants for colors that don't change: + +```kotlin +val Black = SolidColor(Color.Black) +val White = SolidColor(Color.White) +val Transparent = SolidColor(Color.Transparent) +``` + +## Advanced Techniques + +### Layering + +Build complex icons with multiple layers: + +```kotlin +fun complexIcon(builder: Builder) { + // Layer 1: Background + builder.addPath(bgPath, fill = SolidColor(Color.White)) + + // Layer 2: Shadow + builder.addPath(shadowPath, fill = SolidColor(Color.Black), fillAlpha = 0.2f) + + // Layer 3: Main shape + builder.addPath(mainPath, fill = SolidColor(Color.Blue)) + + // Layer 4: Highlight + builder.addPath(highlightPath, fill = SolidColor(Color.White), fillAlpha = 0.3f) + + // Layer 5: Stroke + builder.addPath(outlinePath, stroke = SolidColor(Color.Black), strokeLineWidth = 1f) +} +``` + +**Render order**: Bottom to top (first addPath = bottom layer) + +### Alpha for Visual Effects + +```kotlin +// Shadow +builder.addPath(shadowPath, fill = Black, fillAlpha = 0.4f) + +// Highlight +builder.addPath(highlightPath, fill = White, fillAlpha = 0.2f) + +// Glass effect +builder.addPath(glassPath, fill = White, fillAlpha = 0.1f) +``` + +### Stroke Styles + +```kotlin +// Outline only +builder.addPath(path, stroke = Black, strokeLineWidth = 1.5f) + +// Fill + outline +builder.addPath(path, fill = fgColor, stroke = Black, strokeLineWidth = 1f) + +// Dashed (not supported directly, use multiple segments) +``` + +## Composable Icon Pattern + +Wrap ImageVector in a Composable for reusability: + +```kotlin +@Composable +fun MyCustomIcon( + modifier: Modifier = Modifier, + tint: Color = Color.Unspecified +) { + Image( + painter = rememberVectorPainter(myIconVector()), + contentDescription = "My custom icon", + modifier = modifier, + colorFilter = if (tint != Color.Unspecified) { + ColorFilter.tint(tint) + } else null + ) +} +``` + +**Usage**: +```kotlin +MyCustomIcon( + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary +) +``` + +## Best Practices + +### DO +✅ Cache generated ImageVectors for performance +✅ Use PathData DSL for readability +✅ Parameterize colors for theme support +✅ Use Material Icons for standard icons +✅ Keep viewport size consistent (e.g., 300×300) +✅ Layer paths from back to front +✅ Use alpha for shadows and highlights + +### DON'T +❌ Generate ImageVectors in @Composable without caching +❌ Hardcode theme-specific colors +❌ Create custom icons for standard Material icons +❌ Use extreme viewport sizes (stay 24-1000dp) +❌ Mix absolute and relative coordinates unnecessarily +❌ Forget to close() paths + +## Icon Organization + +### Structure +``` +commons/icons/ +├── CustomIcons.kt # Icon collection object +├── icons/ +│ ├── Zap.kt # Lightning bolt +│ ├── Relay.kt # Relay indicator +│ └── Bitcoin.kt # Bitcoin symbol +└── builders/ + └── IconBuilder.kt # Shared builder utilities +``` + +### Collection Object +```kotlin +object CustomIcons { + val Zap: ImageVector by lazy { ZapIcon.create() } + val Relay: ImageVector by lazy { RelayIcon.create() } + val Bitcoin: ImageVector by lazy { BitcoinIcon.create() } +} + +// Usage +Icon(CustomIcons.Zap, contentDescription = "Zap") +``` + +## Resources + +- [Compose ImageVector API](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/vector/ImageVector) +- [SVG Path Commands](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths) +- [Material Icons](https://fonts.google.com/icons) +- Robohash implementation: `commons/robohash/` in AmethystMultiplatform diff --git a/.claude/skills/compose-expert/references/shared-composables-catalog.md b/.claude/skills/compose-expert/references/shared-composables-catalog.md new file mode 100644 index 000000000..19f7938fd --- /dev/null +++ b/.claude/skills/compose-expert/references/shared-composables-catalog.md @@ -0,0 +1,281 @@ +# Shared Composables Catalog + +This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`. + +## Directory Structure + +``` +commons/src/commonMain/kotlin/.../commons/ui/ +├── components/ # Reusable UI components +├── screens/ # Screen-level composables +├── theme/ # Theming and styling +└── feed/ # Feed-specific components +``` + +## Components (`ui/components/`) + +### State Visualization + +**LoadingState** - Centered loading indicator with message +```kotlin +@Composable +fun LoadingState(message: String, modifier: Modifier = Modifier) +``` +- Use for: Async operations, data fetching +- Pattern: fillMaxSize, centered Column, CircularProgressIndicator +- Works on: Android, Desktop + +**EmptyState** - Centered empty state with optional refresh +```kotlin +@Composable +fun EmptyState( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + onRefresh: (() -> Unit)? = null, + refreshLabel: String = "Refresh" +) +``` +- Use for: Empty lists, no data scenarios +- Pattern: Centered Column, optional OutlinedButton +- Works on: Android, Desktop + +**ErrorState** - Centered error message with retry +```kotlin +@Composable +fun ErrorState( + message: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, + retryLabel: String = "Try Again" +) +``` +- Use for: Error handling, failed operations +- Pattern: error color, optional Button +- Works on: Android, Desktop + +### Feed-Specific States + +**FeedEmptyState** - Pre-configured empty state for feeds +```kotlin +@Composable +fun FeedEmptyState( + modifier: Modifier = Modifier, + title: String = "Feed is empty", + onRefresh: (() -> Unit)? = null +) +``` + +**FeedErrorState** - Pre-configured error state for feeds +```kotlin +@Composable +fun FeedErrorState( + errorMessage: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null +) +``` + +### Action Buttons + +**Shared Constants**: +```kotlin +val ActionButtonShape = RoundedCornerShape(20.dp) +val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp) +``` + +**AddButton** - Consistent "Add" action button +```kotlin +@Composable +fun AddButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Add", + enabled: Boolean = true +) +``` +- Pattern: OutlinedButton with consistent shape/padding +- Works on: Android, Desktop + +**RemoveButton** - Consistent "Remove" action button +```kotlin +@Composable +fun RemoveButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Remove", + enabled: Boolean = true +) +``` + +### Custom Images + +**RobohashImage** - Deterministic avatar generation +```kotlin +@Composable +fun RobohashImage( + robot: String, // Seed (e.g., pubkey) + modifier: Modifier = Modifier, + contentDescription: String? = null, + loadRobohash: Boolean = true +) + +// Overload with more options +@Composable +fun RobohashImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + colorFilter: ColorFilter? = null, + loadRobohash: Boolean = true +) +``` +- Use for: User avatars, deterministic graphics +- Pattern: Uses CachedRobohash.get(), isLightTheme() detection +- Fallback: Icons.Default.Face +- Works on: Android, Desktop (pure ImageVector) + +**Theme Detection Helper**: +```kotlin +@Composable +private fun isLightTheme(): Boolean { + val background = MaterialTheme.colorScheme.background + return (background.red + background.green + background.blue) / 3 > 0.5f +} +``` + +## Feed Components (`ui/feed/`) + +### FeedHeader + +**FeedHeader** - Screen header with title and relay status +```kotlin +@Composable +fun FeedHeader( + title: String, + connectedRelayCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) +``` +- Pattern: Row with SpaceBetween, title + RelayStatusIndicator +- Works on: Android, Desktop + +**RelayStatusIndicator** - Compact relay connection indicator +```kotlin +@Composable +fun RelayStatusIndicator( + connectedCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) +``` +- Pattern: Status icon + count text + refresh button +- Colors: RelayStatusColors.{Disconnected, Connecting, Connected} +- Visual cues: Check icon (connected), Close icon (disconnected) + +## Screens (`ui/screens/`) + +### Placeholder Pattern + +**PlaceholderScreen** - Generic placeholder +```kotlin +@Composable +fun PlaceholderScreen( + title: String, + description: String, + modifier: Modifier = Modifier +) +``` +- Pattern: Column with title (headlineMedium) + description +- Use for: Unimplemented screens, coming soon features + +**Specific Placeholders**: +- `SearchPlaceholder()` - Search screen +- `MessagesPlaceholder()` - DMs screen +- `NotificationsPlaceholder()` - Notifications screen + +Pattern: Specific implementations wrap PlaceholderScreen with preset text. + +## Custom Icons (`robohash/parts/`) + +### ImageVector Builder Pattern + +Amethyst uses a custom DSL for building ImageVector assets: + +```kotlin +@Composable +fun Face0C3po() { + Image( + painter = rememberVectorPainter( + roboBuilder { + face0C3po(SolidColor(Color.Blue), this) + } + ), + contentDescription = "" + ) +} + +fun face0C3po(fgColor: SolidColor, builder: Builder) { + builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f) + builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f) + // ... +} + +private val pathData1 = PathData { + moveTo(144.5f, 87.5f) + reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f) + // ... path commands +} +``` + +**roboBuilder** - Custom ImageVector.Builder DSL +- Located in: `commons/robohash/` +- Pattern: Builder-based, composable paths +- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each) +- Colors: Dynamic (fgColor parameter) + Black constants + +### CachedRobohash + +```kotlin +CachedRobohash.get(seed: String, isLight: Boolean): ImageVector +``` +- Deterministic: Same seed → same avatar +- Theme-aware: Different colors for light/dark +- Cached: Performance optimization +- Pure ImageVector: Works on all platforms + +## Sharing Guidelines + +### Always Share +- State visualization (Loading, Empty, Error) +- Action buttons with consistent styling +- Generic placeholders +- Custom ImageVector icons +- Material3 themed components +- Theme utilities (isLightTheme) + +### Platform-Specific (Delegate to Experts) +- Navigation structure (android-expert, desktop-expert) +- Screen layouts and scaffolds +- Platform system integrations +- Gesture handling specifics + +### Decision Framework +1. **Can it use Material3 primitives?** → Share +2. **Does it need platform system APIs?** → Platform-specific +3. **Is it a visual component without navigation?** → Share +4. **Does it require platform UX patterns?** → Ask platform expert + +## Material3 Usage + +All shared composables use Material3: +- `MaterialTheme.colorScheme.*` for colors +- `MaterialTheme.typography.*` for text styles +- `OutlinedButton`, `Button`, `IconButton` for actions +- `CircularProgressIndicator` for loading +- `Icon`, `Image` for visuals + +This ensures consistent theming across Android and Desktop. diff --git a/.claude/skills/compose-expert/references/state-patterns.md b/.claude/skills/compose-expert/references/state-patterns.md new file mode 100644 index 000000000..3015f766b --- /dev/null +++ b/.claude/skills/compose-expert/references/state-patterns.md @@ -0,0 +1,334 @@ +# Compose State Management Patterns + +Visual guide to state management in Compose Multiplatform. For Kotlin-specific patterns (StateFlow, sealed classes), see `kotlin-expert` skill. + +## Core State Functions + +### remember + +Cache values across recompositions: + +```kotlin +@Composable +fun Counter() { + var count by remember { mutableStateOf(0) } + + Button(onClick = { count++ }) { + Text("Clicked $count times") + } +} +``` + +**When to use**: Simple UI state (toggles, counters, text input) +**Visual pattern**: Button press → state changes → UI updates + +### derivedStateOf + +Compute state from other state, recompose only when result changes: + +```kotlin +@Composable +fun ScrollToTopButton(listState: LazyListState) { + // Only recomposes when showButton value changes (not every scroll pixel) + val showButton by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 + } + } + + if (showButton) { + FloatingActionButton(onClick = { /* scroll to top */ }) { + Icon(Icons.Default.ArrowUpward, null) + } + } +} +``` + +**When to use**: Input state changes frequently, but derived result changes rarely +**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → FAB visibility +**Performance**: Prevents recomposition on every scroll event + +### produceState + +Convert non-Compose state into Compose state: + +```kotlin +@Composable +fun LoadUserProfile(userId: String): State { + return produceState(initialValue = null, userId) { + value = repository.fetchUser(userId) + } +} + +@Composable +fun ProfileScreen(userId: String) { + val user by LoadUserProfile(userId) + + when (user) { + null -> LoadingState("Loading profile...") + else -> ProfileCard(user!!) + } +} +``` + +**When to use**: Convert Flow, LiveData, callbacks into Compose state +**Visual pattern**: Async operation → state updates → UI reflects changes +**Lifecycle**: Coroutine cancelled when composable leaves composition + +## State Hoisting Pattern + +Move state up to make composables reusable and testable: + +### Before (Stateful) +```kotlin +@Composable +fun SearchBar() { + var query by remember { mutableStateOf("") } + + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Search...") } + ) +} +``` +❌ Hard to test, can't control state externally + +### After (Stateless) +```kotlin +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + TextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Search...") }, + modifier = modifier + ) +} + +@Composable +fun SearchScreen() { + var query by remember { mutableStateOf("") } + + Column { + SearchBar(query = query, onQueryChange = { query = it }) + SearchResults(query = query) + } +} +``` +✅ Reusable, testable, state controlled by parent + +**Hoisting principle**: State goes up, events go down +- State: `query: String` (read-only) +- Events: `onQueryChange: (String) -> Unit` (write-only) + +## Amethyst State Patterns + +### Theme-Aware State + +```kotlin +@Composable +private fun isLightTheme(): Boolean { + val background = MaterialTheme.colorScheme.background + return (background.red + background.green + background.blue) / 3 > 0.5f +} + +@Composable +fun ThemedContent() { + val isDark = !isLightTheme() + // Adjust visuals based on theme + val iconTint = if (isDark) Color.White else Color.Black +} +``` + +**Pattern**: Derive state from MaterialTheme +**Visual**: Component adapts to light/dark theme automatically + +### Relay Status State + +```kotlin +@Composable +fun RelayStatusIndicator( + connectedCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) { + val statusColor = when { + connectedCount == 0 -> RelayStatusColors.Disconnected + connectedCount < 3 -> RelayStatusColors.Connecting + else -> RelayStatusColors.Connected + } + + Icon( + imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close, + tint = statusColor + ) +} +``` + +**Pattern**: Visual state derived from domain state +**Visual mapping**: +- 0 relays → Red + X icon +- 1-2 relays → Yellow + Check icon +- 3+ relays → Green + Check icon + +### Loading/Empty/Error States + +```kotlin +@Composable +fun FeedScreen(viewModel: FeedViewModel) { + val uiState by viewModel.uiState.collectAsState() + + when (uiState) { + is UiState.Loading -> LoadingState("Loading feed...") + is UiState.Empty -> FeedEmptyState(onRefresh = { viewModel.refresh() }) + is UiState.Error -> FeedErrorState( + errorMessage = uiState.message, + onRetry = { viewModel.retry() } + ) + is UiState.Success -> LazyColumn { + items(uiState.items) { FeedItem(it) } + } + } +} +``` + +**Pattern**: Sealed class → visual state component +**Components**: +- `LoadingState` - Progress indicator +- `EmptyState` - Empty message + refresh +- `ErrorState` - Error message + retry +- Success - Actual content + +## Common Patterns + +### Toggle State +```kotlin +var isExpanded by remember { mutableStateOf(false) } + +IconButton(onClick = { isExpanded = !isExpanded }) { + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand" + ) +} + +if (isExpanded) { + Text("Expanded content...") +} +``` + +### List State with Actions +```kotlin +var items by remember { mutableStateOf(listOf("Item 1", "Item 2")) } + +Column { + AddButton(onClick = { + items = items + "Item ${items.size + 1}" + }) + + items.forEachIndexed { index, item -> + Row { + Text(item) + RemoveButton(onClick = { + items = items.filterIndexed { i, _ -> i != index } + }) + } + } +} +``` + +### TextField State +```kotlin +var text by remember { mutableStateOf("") } + +TextField( + value = text, + onValueChange = { text = it }, + label = { Text("Enter text") } +) +``` + +## Performance Patterns + +### Avoid Unnecessary Recomposition +```kotlin +// ❌ Bad: Recomposes on every scroll position change +@Composable +fun BadScrollButton(scrollState: ScrollState) { + if (scrollState.value > 100) { // scrollState.value changes constantly + Button(onClick = { /* ... */ }) { Text("Scroll to Top") } + } +} + +// ✅ Good: Only recomposes when visibility changes +@Composable +fun GoodScrollButton(scrollState: ScrollState) { + val showButton by remember { + derivedStateOf { scrollState.value > 100 } + } + + if (showButton) { + Button(onClick = { /* ... */ }) { Text("Scroll to Top") } + } +} +``` + +### Stable Parameters +Use `@Immutable` data classes (see `kotlin-expert`) to prevent recomposition: + +```kotlin +@Immutable +data class UserProfile(val name: String, val avatar: String) + +@Composable +fun ProfileCard(profile: UserProfile) { + // Only recomposes when profile instance changes + Row { + RobohashImage(robot = profile.avatar) + Text(profile.name) + } +} +``` + +## Integration with Kotlin State + +For ViewModel state, Flow, StateFlow → See `kotlin-expert` skill + +Common integration pattern: +```kotlin +// ViewModel (Kotlin state) +class FeedViewModel { + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() +} + +// Composable (Compose state) +@Composable +fun FeedScreen(viewModel: FeedViewModel) { + val uiState by viewModel.uiState.collectAsState() + // Use uiState to render UI +} +``` + +## Quick Reference + +| Function | Use Case | Recomposes When | +|----------|----------|----------------| +| `remember { mutableStateOf() }` | Local UI state | State value changes | +| `derivedStateOf { }` | Computed state | Derived result changes | +| `produceState { }` | Async/Flow → State | Async operation updates value | +| `collectAsState()` | Flow → State | Flow emits new value | +| State hoisting | Reusable components | Parent passes new state | + +## Sources + +State management patterns based on: +- [State and Jetpack Compose - Android Developers](https://developer.android.com/develop/ui/compose/state) +- [When should I use derivedStateOf?](https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b) +- [Advanced State and Side Effects](https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects) +- AmethystMultiplatform codebase patterns (2025) diff --git a/.claude/skills/compose-expert/scripts/find-composables.sh b/.claude/skills/compose-expert/scripts/find-composables.sh new file mode 100755 index 000000000..436a3cefe --- /dev/null +++ b/.claude/skills/compose-expert/scripts/find-composables.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Find all @Composable functions in the codebase + +set -e + +# Default to current directory if no path provided +SEARCH_PATH="${1:-.}" + +echo "Searching for @Composable functions in: $SEARCH_PATH" +echo "================================================" +echo "" + +# Find all @Composable functions with file paths and line numbers +grep -r -n "@Composable" "$SEARCH_PATH" \ + --include="*.kt" \ + --exclude-dir=build \ + --exclude-dir=.gradle \ + | while IFS=: read -r file line content; do + # Extract function name if possible + if [[ $content =~ fun[[:space:]]+([a-zA-Z0-9_]+) ]]; then + func_name="${BASH_REMATCH[1]}" + echo "$file:$line - $func_name" + else + echo "$file:$line" + fi + done + +echo "" +echo "Total @Composable functions found:" +grep -r "@Composable" "$SEARCH_PATH" \ + --include="*.kt" \ + --exclude-dir=build \ + --exclude-dir=.gradle \ + | wc -l diff --git a/.claude/skills/desktop-expert/SKILL.md b/.claude/skills/desktop-expert/SKILL.md new file mode 100644 index 000000000..8cbcaaf8a --- /dev/null +++ b/.claude/skills/desktop-expert/SKILL.md @@ -0,0 +1,748 @@ +# Desktop Expert + +Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles. + +## When to Use This Skill + +**Auto-invoke when:** +- Working with `desktopApp/` module files +- Using Desktop-only APIs: `Window`, `Tray`, `MenuBar`, `Dialog` +- Implementing keyboard shortcuts, menu systems +- Desktop navigation (NavigationRail, multi-window) +- File system operations (file pickers, drag-drop) +- OS-specific behavior (macOS, Windows, Linux) +- Desktop UX patterns (keyboard-first, tooltips) + +**Delegate to:** +- **kotlin-multiplatform**: Shared code questions, `jvmMain` source set structure +- **gradle-expert**: All `build.gradle.kts` issues, dependency conflicts +- **compose-expert**: General Compose patterns, `@Composable` best practices, Material3 + +## Scope + +**In scope:** +- Desktop-only Compose APIs +- Window management, positioning, state +- MenuBar + keyboard shortcuts (OS-specific) +- System Tray integration +- Desktop navigation patterns (NavigationRail) +- File dialogs, Desktop.getDesktop() +- OS conventions (macOS vs Windows vs Linux) +- Desktop UX principles + +**Out of scope:** +- Build configuration → **gradle-expert** +- Shared composables → **compose-expert** +- KMP structure → **kotlin-multiplatform** + +--- + +## 1. Desktop Entry Point + +### application {} DSL + +Desktop apps start with the `application {}` block: + +```kotlin +// desktopApp/src/jvmMain/kotlin/Main.kt +fun main() = application { + val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) + ) + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Amethyst" + ) { + MenuBar { /* ... */ } + App() + } +} +``` + +**Key points:** +- `application {}` is the root composable (JVM-only) +- `Window()` creates the main window +- `rememberWindowState()` manages size/position +- `onCloseRequest` handles window close + +**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:87-138` + +--- + +## 2. Window Management + +### WindowState + +```kotlin +val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) +) + +Window( + state = windowState, + title = "My App", + resizable = true, + onCloseRequest = ::exitApplication +) { + // Content +} +``` + +### Multiple Windows + +```kotlin +fun main() = application { + var showSettings by remember { mutableStateOf(false) } + + Window(onCloseRequest = ::exitApplication, title = "Main") { + Button(onClick = { showSettings = true }) { + Text("Open Settings") + } + } + + if (showSettings) { + Window( + onCloseRequest = { showSettings = false }, + title = "Settings" + ) { + // Settings UI + } + } +} +``` + +**Pattern:** Use state to control window visibility conditionally. + +--- + +## 3. MenuBar System + +### Basic MenuBar + +```kotlin +Window(onCloseRequest = ::exitApplication, title = "App") { + MenuBar { + Menu("File") { + Item("New Note", onClick = { /* ... */ }) + Separator() + Item("Quit", onClick = ::exitApplication) + } + Menu("Edit") { + Item("Copy", onClick = { /* ... */ }) + Item("Paste", onClick = { /* ... */ }) + } + } + App() +} +``` + +### Keyboard Shortcuts (OS-Aware) + +**Current issue:** Main.kt hardcodes `ctrl = true` (Main.kt:105, 111, 117, 122, 123). + +**OS-specific shortcuts:** + +```kotlin +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut + +// Detect OS +val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = if (isMacOS) { + KeyShortcut(Key.N, meta = true) // Cmd+N on macOS + } else { + KeyShortcut(Key.N, ctrl = true) // Ctrl+N on Win/Linux + }, + onClick = { /* ... */ } + ) + Item( + "Settings", + shortcut = if (isMacOS) { + KeyShortcut(Key.Comma, meta = true) // Cmd+, on macOS + } else { + KeyShortcut(Key.Comma, ctrl = true) // Ctrl+, on Win/Linux + }, + onClick = { /* ... */ } + ) + Separator() + Item( + "Quit", + shortcut = if (isMacOS) { + KeyShortcut(Key.Q, meta = true) // Cmd+Q on macOS + } else { + KeyShortcut(Key.Q, ctrl = true) // Ctrl+Q on Win/Linux + }, + onClick = ::exitApplication + ) + } +} +``` + +**Standard shortcuts:** + +| Action | macOS | Windows/Linux | +|--------|-------|---------------| +| New | Cmd+N | Ctrl+N | +| Open | Cmd+O | Ctrl+O | +| Save | Cmd+S | Ctrl+S | +| Quit | Cmd+Q | Ctrl+Q (Alt+F4) | +| Settings | Cmd+, | Ctrl+, | +| Copy | Cmd+C | Ctrl+C | +| Paste | Cmd+V | Ctrl+V | +| Undo | Cmd+Z | Ctrl+Z | + +**See:** `references/keyboard-shortcuts.md` for full list. + +--- + +## 4. System Tray + +### Basic Tray + +```kotlin +application { + var isVisible by remember { mutableStateOf(true) } + + Tray( + icon = painterResource("icon.png"), + onAction = { isVisible = true }, + menu = { + Item("Show", onClick = { isVisible = true }) + Separator() + Item("Quit", onClick = ::exitApplication) + } + ) + + if (isVisible) { + Window( + onCloseRequest = { isVisible = false }, // Minimize to tray + title = "App" + ) { + // Content + } + } +} +``` + +**Pattern:** Hide window to tray instead of closing. + +**Current status:** Not implemented in Main.kt. Planned feature. + +--- + +## 5. Desktop Navigation Patterns + +### NavigationRail (Current Pattern) + +Desktop uses **NavigationRail** (vertical sidebar) instead of Android's bottom navigation. + +```kotlin +Row(Modifier.fillMaxSize()) { + // Sidebar + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { currentScreen = AppScreen.Feed } + ) + // More items... + } + + VerticalDivider() + + // Main content area + Box(Modifier.weight(1f).fillMaxHeight()) { + when (currentScreen) { + AppScreen.Feed -> FeedScreen() + // Other screens... + } + } +} +``` + +**See:** Main.kt:191-264 + +**Why NavigationRail?** +- Desktop has horizontal space (1200+ dp width) +- Vertical sidebar is standard desktop pattern +- Always visible (no tabs hidden) +- Icon + label both visible + +**Android comparison:** +- Android: `BottomNavigationBar` (horizontal, bottom) +- Desktop: `NavigationRail` (vertical, left) + +### Multi-Pane Layouts + +Desktop can leverage wide screens: + +```kotlin +Row { + // Left: Navigation + NavigationRail { /* ... */ } + + // Center: Main content + Box(Modifier.weight(0.6f)) { + FeedScreen() + } + + // Right: Details pane (desktop only) + if (selectedNote != null) { + VerticalDivider() + Box(Modifier.weight(0.4f)) { + NoteDetailPane(selectedNote) + } + } +} +``` + +**See:** `references/desktop-navigation.md` + +--- + +## 6. File System Integration + +### File Dialogs + +```kotlin +// File picker (load) +val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD) +fileDialog.isVisible = true +val filePath = fileDialog.file?.let { "${fileDialog.directory}$it" } + +// File picker (save) +val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE) +saveDialog.isVisible = true +val savePath = saveDialog.file?.let { "${saveDialog.directory}$it" } +``` + +**Note:** Compose Desktop doesn't have native file picker composable yet. Use AWT `FileDialog`. + +### Open External URLs + +```kotlin +// jvmMain actual implementation +actual fun openExternalUrl(url: String) { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI(url)) + } +} +``` + +**Pattern:** Define `expect` in `commonMain`, implement `actual` in `jvmMain`. + +### Drag & Drop (Future) + +```kotlin +// Compose Desktop drag-drop (experimental) +Box( + modifier = Modifier + .onExternalDrag( + onDragStart = { /* ... */ }, + onDrag = { /* ... */ }, + onDragExit = { /* ... */ }, + onDrop = { state -> + val dragData = state.dragData + // Handle dropped files + } + ) +) { + Text("Drop files here") +} +``` + +--- + +## 7. OS-Specific Conventions + +### Platform Detection + +```kotlin +val osName = System.getProperty("os.name").lowercase() + +val isMacOS = osName.contains("mac") +val isWindows = osName.contains("win") +val isLinux = osName.contains("nux") || osName.contains("nix") +``` + +### Menu Bar Placement + +| OS | Behavior | +|----|----------| +| **macOS** | System-wide menu bar at top of screen | +| **Windows** | In-window menu bar | +| **Linux** | Varies by desktop environment | + +Compose Desktop `MenuBar` adapts automatically. + +### Keyboard Modifier Keys + +| Modifier | macOS | Windows/Linux | +|----------|-------|---------------| +| Primary | `meta = true` (Cmd) | `ctrl = true` | +| Secondary | `ctrl = true` | `alt = true` | +| Shift | `shift = true` | `shift = true` | + +**Best practice:** Detect OS and use appropriate modifier. + +### System Tray Behavior + +| OS | Tray Location | +|----|---------------| +| **macOS** | Top-right menu bar | +| **Windows** | Bottom-right taskbar | +| **Linux** | Top panel (varies) | + +--- + +## 8. Desktop UX Principles + +### Keyboard-First Design + +**Every action should have:** +1. Mouse/touch interaction +2. Keyboard shortcut (if frequent) +3. Tooltip showing shortcut + +```kotlin +IconButton( + onClick = { /* refresh */ }, + modifier = Modifier.tooltipArea( + tooltip = { + Text("Refresh (${if (isMacOS) "Cmd" else "Ctrl"}+R)") + } + ) +) { + Icon(Icons.Default.Refresh, "Refresh") +} +``` + +### Tooltip Best Practices + +- Show keyboard shortcut in tooltip +- Use native modifier name (Cmd vs Ctrl) +- Brief description + shortcut + +### Context Menus + +Right-click should show context menu: + +```kotlin +// Future: Compose Desktop context menu API +Box( + modifier = Modifier.contextMenuArea( + items = { + listOf( + ContextMenuItem("Copy") { /* ... */ }, + ContextMenuItem("Paste") { /* ... */ } + ) + } + ) +) { + // Content +} +``` + +**Current:** Use popup or custom implementation. + +### Window State Persistence + +Save/restore window size/position: + +```kotlin +// Save on close +windowState.size // DpSize +windowState.position // WindowPosition + +// Restore on launch +val savedWidth = preferences.getInt("window.width", 1200) +val savedHeight = preferences.getInt("window.height", 800) + +val windowState = rememberWindowState( + width = savedWidth.dp, + height = savedHeight.dp +) +``` + +--- + +## 9. Desktop Module Structure + +``` +desktopApp/ +├── build.gradle.kts # Desktop-only build config +└── src/ + └── jvmMain/ + ├── kotlin/ + │ └── com/vitorpamplona/amethyst/desktop/ + │ ├── Main.kt # Entry point, Window, MenuBar + │ ├── network/ + │ │ ├── DesktopHttpClient.kt + │ │ └── DesktopRelayConnectionManager.kt + │ └── ui/ + │ ├── FeedScreen.kt # Desktop screen layouts + │ └── LoginScreen.kt + └── resources/ + ├── icon.icns # macOS icon + ├── icon.ico # Windows icon + └── icon.png # Linux icon +``` + +**Key files:** +- `Main.kt:87-138` - `application {}`, `Window`, `MenuBar` +- `Main.kt:183-264` - NavigationRail pattern +- `build.gradle.kts:45-73` - Desktop packaging config + +--- + +## 10. Packaging & Distribution + +### Build Configuration + +```kotlin +// desktopApp/build.gradle.kts +compose.desktop { + application { + mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + + packageName = "Amethyst" + packageVersion = "1.0.0" + + macOS { + bundleID = "com.vitorpamplona.amethyst.desktop" + iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + } + + windows { + iconFile.set(project.file("src/jvmMain/resources/icon.ico")) + menuGroup = "Amethyst" + } + + linux { + iconFile.set(project.file("src/jvmMain/resources/icon.png")) + } + } + } +} +``` + +**See:** desktopApp/build.gradle.kts:45-73 + +### Gradle Tasks + +```bash +# Run desktop app +./gradlew :desktopApp:run + +# Package for distribution +./gradlew :desktopApp:packageDmg # macOS +./gradlew :desktopApp:packageMsi # Windows +./gradlew :desktopApp:packageDeb # Linux +``` + +**Delegate packaging issues to gradle-expert.** + +--- + +## Common Patterns + +### Pattern: OS-Aware Shortcuts Helper + +```kotlin +// commons/src/jvmMain/kotlin/shortcuts/ShortcutUtils.kt +object DesktopShortcuts { + private val isMacOS = System.getProperty("os.name") + .lowercase().contains("mac") + + fun primary(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + } + + fun primaryShift(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, shift = true) + } else { + KeyShortcut(key, ctrl = true, shift = true) + } + + val modifierName = if (isMacOS) "Cmd" else "Ctrl" +} + +// Usage in MenuBar +Item( + "New Note", + shortcut = DesktopShortcuts.primary(Key.N), + onClick = { /* ... */ } +) +``` + +### Pattern: Shared Composables, Platform Layouts + +```kotlin +// commons/commonMain - Shared NoteCard +@Composable +fun NoteCard(note: NoteDisplayData) { + // Business logic, UI component (shared) +} + +// desktopApp/jvmMain - Desktop layout +@Composable +fun FeedScreen() { + Column { + FeedHeader(/* ... */) // Shared from commons + LazyColumn { + items(notes) { note -> + NoteCard(note) // Shared composable + } + } + } +} + +// amethyst/androidMain - Android layout +@Composable +fun FeedScreen() { + Scaffold( + bottomBar = { BottomNavigationBar() } // Android-specific + ) { + LazyColumn { + items(notes) { note -> + NoteCard(note) // Same shared composable + } + } + } +} +``` + +**Philosophy:** Share UI components (cards, buttons), keep navigation/layout platform-specific. + +--- + +## Resources + +### Official Documentation +- [Desktop-only API | Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html) +- [Top-level windows management](https://kotlinlang.org/docs/multiplatform/compose-desktop-top-level-windows-management.html) +- [Tray/MenuBar Tutorial](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Tray_Notifications_MenuBar_new/README.md) + +### Bundled References +- `references/desktop-compose-apis.md` - Complete Desktop API catalog +- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns +- `references/keyboard-shortcuts.md` - Standard shortcuts by OS +- `references/os-detection.md` - Platform detection patterns + +### Codebase Examples +- Main.kt:87-138 - Window, MenuBar entry point +- Main.kt:183-264 - NavigationRail pattern +- FeedScreen.kt:49-136 - Desktop screen layout +- LoginScreen.kt:44-97 - Centered desktop login + +--- + +## Questions to Ask + +When working on desktop features: + +1. **Should this be shared or desktop-only?** + - Business logic → Share in `commonMain` + - Navigation/layout → Keep in `desktopApp/jvmMain` + +2. **Does this need OS-specific behavior?** + - Keyboard shortcuts → Yes (Cmd vs Ctrl) + - File paths → Yes (separators) + - Icons → Yes (per-OS formats) + +3. **Is there a desktop UX convention?** + - Check MenuBar standards + - Consider keyboard-first design + - Tooltips for all actions + +4. **Does this need gradle-expert?** + - Any `build.gradle.kts` changes → Delegate + - Packaging/distribution issues → Delegate + +--- + +## Anti-Patterns + +❌ **Hardcoding Ctrl everywhere** +```kotlin +// Main.kt:105 - Current issue +shortcut = KeyShortcut(Key.N, ctrl = true) // Wrong on macOS +``` + +✅ **OS-aware shortcuts** +```kotlin +shortcut = DesktopShortcuts.primary(Key.N) +``` + +--- + +❌ **Using Android navigation on Desktop** +```kotlin +Scaffold(bottomBar = { BottomNavigationBar() }) // Wrong for desktop +``` + +✅ **NavigationRail for desktop** +```kotlin +Row { + NavigationRail { /* ... */ } + MainContent() +} +``` + +--- + +❌ **No keyboard shortcuts** +```kotlin +IconButton(onClick = { refresh() }) { + Icon(Icons.Default.Refresh, "Refresh") +} +``` + +✅ **Shortcuts + tooltips** +```kotlin +IconButton( + onClick = { refresh() }, + modifier = Modifier.tooltipArea("Refresh (Cmd+R)") +) { + Icon(Icons.Default.Refresh, "Refresh") +} +``` + +--- + +## Next Steps + +When implementing desktop features: + +1. **Read** `references/desktop-compose-apis.md` for API catalog +2. **Check** `references/keyboard-shortcuts.md` for standard shortcuts +3. **Reference** Main.kt:87-264 for current patterns +4. **Test** on all 3 platforms (macOS, Windows, Linux) if possible +5. **Delegate** build issues to gradle-expert +6. **Share** UI components via compose-expert, not desktop-expert + +--- + +**Version:** 1.0.0 +**Last Updated:** 2025-12-30 +**Codebase Reference:** AmethystMultiplatform commit 258c4e011 diff --git a/.claude/skills/desktop-expert/references/desktop-compose-apis.md b/.claude/skills/desktop-expert/references/desktop-compose-apis.md new file mode 100644 index 000000000..bcb773321 --- /dev/null +++ b/.claude/skills/desktop-expert/references/desktop-compose-apis.md @@ -0,0 +1,597 @@ +# Desktop Compose APIs Catalog + +Complete reference for Compose Multiplatform Desktop-only APIs. + +## Window Management + +### application + +Root entry point for desktop apps. + +```kotlin +fun main() = application { + Window(onCloseRequest = ::exitApplication) { + Text("Hello Desktop") + } +} +``` + +### Window + +Creates a window. + +```kotlin +Window( + onCloseRequest: () -> Unit, + state: WindowState = rememberWindowState(), + visible: Boolean = true, + title: String = "Untitled", + icon: Painter? = null, + undecorated: Boolean = false, + transparent: Boolean = false, + resizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false }, + onKeyEvent: ((KeyEvent) -> Boolean) = { false }, + content: @Composable FrameWindowScope.() -> Unit +) +``` + +**Example:** +```kotlin +val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) +) + +Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "My App", + resizable = true +) { + // Content +} +``` + +### rememberWindowState + +Manages window size and position. + +```kotlin +@Composable +fun rememberWindowState( + placement: WindowPlacement = WindowPlacement.Floating, + isMinimized: Boolean = false, + position: WindowPosition = WindowPosition.PlatformDefault, + width: Dp = Dp.Unspecified, + height: Dp = Dp.Unspecified +): WindowState +``` + +**WindowPlacement:** +- `Floating` - Normal window +- `Maximized` - Fullscreen +- `Fullscreen` - Fullscreen without decorations + +**WindowPosition:** +- `PlatformDefault` - OS decides +- `Aligned(alignment)` - Center, TopStart, etc. +- `Absolute(x, y)` - Fixed position in pixels + +### DialogWindow + +Modal dialog. + +```kotlin +DialogWindow( + onCloseRequest: () -> Unit, + state: DialogState = rememberDialogState(), + visible: Boolean = true, + title: String = "Dialog", + icon: Painter? = null, + undecorated: Boolean = false, + transparent: Boolean = false, + resizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + content: @Composable DialogWindowScope.() -> Unit +) +``` + +**Example:** +```kotlin +var showDialog by remember { mutableStateOf(false) } + +if (showDialog) { + DialogWindow( + onCloseRequest = { showDialog = false }, + title = "Confirm" + ) { + Column(Modifier.padding(16.dp)) { + Text("Are you sure?") + Row { + Button(onClick = { showDialog = false }) { + Text("Cancel") + } + Button(onClick = { /* confirm */ }) { + Text("OK") + } + } + } + } +} +``` + +--- + +## MenuBar + +### MenuBar + +Native menu bar for windows. + +```kotlin +@Composable +fun FrameWindowScope.MenuBar( + content: @Composable MenuBarScope.() -> Unit +) +``` + +**Example:** +```kotlin +Window(onCloseRequest = ::exitApplication) { + MenuBar { + Menu("File") { + Item("New", onClick = { /* ... */ }) + Item("Open", onClick = { /* ... */ }) + Separator() + Item("Quit", onClick = ::exitApplication) + } + Menu("Edit") { + Item("Copy", onClick = { /* ... */ }) + Item("Paste", onClick = { /* ... */ }) + } + } +} +``` + +### Menu + +Top-level menu. + +```kotlin +@Composable +fun MenuBarScope.Menu( + text: String, + mnemonic: Char? = null, + enabled: Boolean = true, + content: @Composable MenuScope.() -> Unit +) +``` + +### Item + +Menu item. + +```kotlin +@Composable +fun MenuScope.Item( + text: String, + onClick: () -> Unit, + shortcut: KeyShortcut? = null, + mnemonic: Char? = null, + enabled: Boolean = true, + icon: Painter? = null +) +``` + +**With keyboard shortcut:** +```kotlin +Item( + text = "Save", + onClick = { save() }, + shortcut = KeyShortcut(Key.S, ctrl = true), + icon = painterResource("save.png") +) +``` + +### Separator + +Menu separator line. + +```kotlin +@Composable +fun MenuScope.Separator() +``` + +### CheckboxItem + +Toggleable menu item. + +```kotlin +@Composable +fun MenuScope.CheckboxItem( + text: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + shortcut: KeyShortcut? = null, + mnemonic: Char? = null, + enabled: Boolean = true +) +``` + +**Example:** +```kotlin +var darkMode by remember { mutableStateOf(false) } + +Menu("View") { + CheckboxItem( + text = "Dark Mode", + checked = darkMode, + onCheckedChange = { darkMode = it }, + shortcut = KeyShortcut(Key.D, ctrl = true) + ) +} +``` + +### RadioButtonItem + +Radio button menu item. + +```kotlin +@Composable +fun MenuScope.RadioButtonItem( + text: String, + selected: Boolean, + onClick: () -> Unit, + shortcut: KeyShortcut? = null, + mnemonic: Char? = null, + enabled: Boolean = true +) +``` + +--- + +## System Tray + +### Tray + +System tray icon with menu. + +```kotlin +@Composable +fun ApplicationScope.Tray( + icon: Painter, + state: TrayState = rememberTrayState(), + tooltip: String? = null, + onAction: () -> Unit = {}, + menu: @Composable MenuScope.() -> Unit = {} +) +``` + +**Example:** +```kotlin +application { + var isVisible by remember { mutableStateOf(true) } + + Tray( + icon = painterResource("tray-icon.png"), + tooltip = "My App", + onAction = { isVisible = true }, + menu = { + Item("Show Window", onClick = { isVisible = true }) + Separator() + Item("Quit", onClick = ::exitApplication) + } + ) + + if (isVisible) { + Window( + onCloseRequest = { isVisible = false }, + title = "App" + ) { + // Content + } + } +} +``` + +### rememberTrayState + +Manages tray state. + +```kotlin +@Composable +fun rememberTrayState(): TrayState +``` + +--- + +## Notifications + +### Notification (via Tray) + +Show desktop notifications through tray. + +```kotlin +val trayState = rememberTrayState() + +Tray( + icon = painterResource("icon.png"), + state = trayState +) + +// Send notification +LaunchedEffect(Unit) { + trayState.sendNotification( + Notification( + title = "Message", + message = "You have a new message", + type = Notification.Type.Info + ) + ) +} +``` + +**Notification types:** +- `Info` - Information +- `Warning` - Warning +- `Error` - Error + +--- + +## Keyboard + +### KeyShortcut + +Keyboard shortcut definition. + +```kotlin +data class KeyShortcut( + val key: Key, + val ctrl: Boolean = false, + val meta: Boolean = false, + val alt: Boolean = false, + val shift: Boolean = false +) +``` + +**Examples:** +```kotlin +// Ctrl+S (Windows/Linux) +KeyShortcut(Key.S, ctrl = true) + +// Cmd+S (macOS) +KeyShortcut(Key.S, meta = true) + +// Ctrl+Shift+N +KeyShortcut(Key.N, ctrl = true, shift = true) + +// Alt+F4 +KeyShortcut(Key.F4, alt = true) +``` + +### onPreviewKeyEvent / onKeyEvent + +Window-level keyboard handlers. + +```kotlin +Window( + onCloseRequest = ::exitApplication, + onPreviewKeyEvent = { event -> + if (event.key == Key.Escape && event.type == KeyEventType.KeyDown) { + // Handle Escape + true // Consume event + } else { + false // Propagate + } + } +) { + // Content +} +``` + +--- + +## Mouse + +### PointerMoveFilter (Deprecated, use Modifier.pointerInput) + +```kotlin +Box( + modifier = Modifier + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + // Handle mouse events + } + } + } +) +``` + +### Mouse cursor + +```kotlin +Box( + modifier = Modifier.pointerHoverIcon( + icon = PointerIcon(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) + ) +) { + Text("Hover me") +} +``` + +**Cursor types:** +- `DEFAULT_CURSOR` +- `HAND_CURSOR` +- `TEXT_CURSOR` +- `CROSSHAIR_CURSOR` +- `WAIT_CURSOR` +- `MOVE_CURSOR` +- `E_RESIZE_CURSOR`, `W_RESIZE_CURSOR`, etc. + +--- + +## Drag & Drop (Experimental) + +### onExternalDrag + +Handle drag-and-drop from external sources. + +```kotlin +Box( + modifier = Modifier + .size(200.dp) + .background(Color.LightGray) + .onExternalDrag( + onDragStart = { externalDragValue -> + println("Drag started") + }, + onDrag = { externalDragValue -> + println("Dragging: ${externalDragValue.dragData}") + }, + onDragExit = { + println("Drag exited") + }, + onDrop = { externalDragValue -> + val dragData = externalDragValue.dragData + when (dragData) { + is DragData.FilesList -> { + println("Files dropped: ${dragData.readFiles()}") + } + is DragData.Text -> { + println("Text dropped: ${dragData.readText()}") + } + else -> {} + } + } + ) +) { + Text("Drop files here", Modifier.align(Alignment.Center)) +} +``` + +--- + +## Resources + +### painterResource + +Load images from resources. + +```kotlin +val icon = painterResource("icon.png") + +Icon( + painter = icon, + contentDescription = "App icon" +) +``` + +**Resource location:** `src/jvmMain/resources/` + +--- + +## Platform Integration + +### Desktop.getDesktop() (AWT) + +Access system desktop features (not Compose API, but commonly used). + +```kotlin +import java.awt.Desktop +import java.net.URI + +// Open URL in browser +if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI("https://example.com")) +} + +// Open file with default app +Desktop.getDesktop().open(File("/path/to/file.pdf")) + +// Open email client +Desktop.getDesktop().mail(URI("mailto:user@example.com")) +``` + +### FileDialog (AWT) + +File picker dialogs. + +```kotlin +import java.awt.FileDialog +import java.awt.Frame + +// Open file +val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD) +fileDialog.isVisible = true +val selectedFile = fileDialog.file +val directory = fileDialog.directory + +// Save file +val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE) +saveDialog.file = "document.txt" +saveDialog.isVisible = true +``` + +--- + +## SwingPanel (Interop) + +Embed Swing components in Compose. + +```kotlin +import androidx.compose.ui.awt.SwingPanel +import javax.swing.JButton + +SwingPanel( + factory = { + JButton("Swing Button").apply { + addActionListener { + println("Swing button clicked") + } + } + }, + modifier = Modifier.size(200.dp, 50.dp) +) +``` + +--- + +## ComposePanel (Reverse Interop) + +Embed Compose in Swing. + +```kotlin +import androidx.compose.ui.awt.ComposePanel +import javax.swing.JFrame + +val frame = JFrame("Swing Frame") +val composePanel = ComposePanel() + +composePanel.setContent { + Text("Compose in Swing") +} + +frame.contentPane.add(composePanel) +frame.setSize(400, 300) +frame.isVisible = true +``` + +--- + +## Version Requirements + +- **Kotlin:** 2.0+ +- **Compose Multiplatform:** 1.7.0+ +- **JVM Target:** 11+ (recommend 21) + +**See also:** +- [Official Desktop API docs](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html) +- [Compose Multiplatform repo](https://github.com/JetBrains/compose-multiplatform) diff --git a/.claude/skills/desktop-expert/references/desktop-navigation.md b/.claude/skills/desktop-expert/references/desktop-navigation.md new file mode 100644 index 000000000..01d192767 --- /dev/null +++ b/.claude/skills/desktop-expert/references/desktop-navigation.md @@ -0,0 +1,464 @@ +# Desktop Navigation Patterns + +Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform. + +## Core Difference + +| Platform | Pattern | Location | Rationale | +|----------|---------|----------|-----------| +| **Android** | Bottom Navigation Bar | Horizontal, bottom | Thumb reach on mobile | +| **Desktop** | Navigation Rail | Vertical, left sidebar | Horizontal screen space | + +--- + +## Desktop: NavigationRail + +### Current Implementation + +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:191-264` + +```kotlin +@Composable +fun MainContent( + currentScreen: AppScreen, + onScreenChange: (AppScreen) -> Unit, + // ... +) { + Row(Modifier.fillMaxSize()) { + // LEFT: Vertical Sidebar (NavigationRail) + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + Spacer(Modifier.height(16.dp)) + + // Top navigation items + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, "Search") }, + label = { Text("Search") }, + selected = currentScreen == AppScreen.Search, + onClick = { onScreenChange(AppScreen.Search) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == AppScreen.Messages, + onClick = { onScreenChange(AppScreen.Messages) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == AppScreen.Notifications, + onClick = { onScreenChange(AppScreen.Notifications) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == AppScreen.Profile, + onClick = { onScreenChange(AppScreen.Profile) } + ) + + // Push Settings to bottom + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == AppScreen.Settings, + onClick = { onScreenChange(AppScreen.Settings) } + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // RIGHT: Main Content Area + Box(modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)) { + when (currentScreen) { + AppScreen.Feed -> FeedScreen(relayManager) + AppScreen.Search -> SearchPlaceholder() + AppScreen.Messages -> MessagesPlaceholder() + AppScreen.Notifications -> NotificationsPlaceholder() + AppScreen.Profile -> ProfileScreen(account, accountManager) + AppScreen.Settings -> RelaySettingsScreen(relayManager) + } + } + } +} +``` + +### Layout Structure + +``` +┌────────────────────────────────────────┐ +│ [Menu Bar: File, Edit, View, Help] │ ← MenuBar (OS-native) +├──────┬─────────────────────────────────┤ +│ │ │ +│ [🏠] │ │ +│ Feed │ │ +│ │ │ +│ [🔍] │ Main Content Area │ +│Search│ (Feed, Messages, etc.) │ +│ │ │ +│ [✉️] │ │ +│ DMs │ │ +│ │ │ +│ [🔔] │ │ +│Alerts│ │ +│ │ │ +│ [👤] │ │ +│Profile │ +│ │ │ +│ ─ │ │ +│ [⚙️] │ │ +│Settings │ +│ │ │ +└──────┴─────────────────────────────────┘ + 80dp Remaining width (weight=1f) +``` + +### Key Features + +1. **Always visible:** All nav items visible at once +2. **Icon + Label:** Both shown (not just icons) +3. **Vertical list:** Natural reading order +4. **Settings at bottom:** Separated by divider + Spacer.weight(1f) +5. **80dp width:** Standard NavigationRail width + +--- + +## Android: BottomNavigationBar (Future) + +### Expected Implementation + +**Location:** `amethyst/src/androidMain/kotlin/...` (not yet implemented) + +```kotlin +@Composable +fun MainScreen( + currentScreen: AppScreen, + onScreenChange: (AppScreen) -> Unit +) { + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Search, "Search") }, + label = { Text("Search") }, + selected = currentScreen == AppScreen.Search, + onClick = { onScreenChange(AppScreen.Search) } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Email, "Messages") }, + label = { Text("Messages") }, + selected = currentScreen == AppScreen.Messages, + onClick = { onScreenChange(AppScreen.Messages) } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Person, "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == AppScreen.Profile, + onClick = { onScreenChange(AppScreen.Profile) } + ) + } + } + ) { paddingValues -> + Box(Modifier.padding(paddingValues)) { + when (currentScreen) { + AppScreen.Feed -> FeedScreen() + AppScreen.Search -> SearchScreen() + AppScreen.Messages -> MessagesScreen() + AppScreen.Profile -> ProfileScreen() + // Settings accessed via Profile or overflow menu + } + } + } +} +``` + +### Layout Structure + +``` +┌─────────────────────────────────────┐ +│ │ +│ │ +│ Main Content Area │ +│ (Feed, Messages, etc.) │ +│ │ +│ │ +│ │ +├─────────────────────────────────────┤ +│ [🏠] [🔍] [✉️] [👤] │ ← NavigationBar +│ Feed Search DMs Profile │ +└─────────────────────────────────────┘ +``` + +### Key Differences from Desktop + +1. **Bottom placement:** Thumb reach +2. **Horizontal layout:** Limited vertical space +3. **Fewer items:** 3-5 primary destinations +4. **Label optional:** Can hide on small screens +5. **Settings hidden:** In profile or overflow + +--- + +## Shared Navigation State + +Both platforms use the same `AppScreen` enum from `commons`. + +**File:** `commons/src/commonMain/kotlin/.../navigation/AppScreen.kt` (expected) + +```kotlin +// Shared navigation destinations +enum class AppScreen { + Feed, + Search, + Messages, + Notifications, + Profile, + Settings +} +``` + +**State management (shared):** + +```kotlin +// commons/src/jvmAndroid/kotlin/.../navigation/NavigationViewModel.kt +class NavigationViewModel : ViewModel() { + private val _currentScreen = MutableStateFlow(AppScreen.Feed) + val currentScreen: StateFlow = _currentScreen.asStateFlow() + + fun navigateTo(screen: AppScreen) { + _currentScreen.value = screen + } +} +``` + +--- + +## Multi-Pane Desktop Layout (Advanced) + +Desktop can utilize horizontal space for multi-pane layouts. + +### Two-Pane Layout + +```kotlin +Row(Modifier.fillMaxSize()) { + // Left: NavigationRail (fixed 80dp) + NavigationRail { /* ... */ } + + VerticalDivider() + + // Center: Main content (60% width) + Box(Modifier.weight(0.6f)) { + FeedScreen() + } + + // Right: Detail pane (40% width, conditional) + if (selectedNote != null) { + VerticalDivider() + Box(Modifier.weight(0.4f)) { + NoteDetailPane(selectedNote) + } + } +} +``` + +### Layout: + +``` +┌──────┬───────────────────┬─────────────┐ +│ │ │ │ +│ Nav │ Feed List │ Detail │ +│ Rail │ (60%) │ Pane │ +│ │ │ (40%) │ +│ │ │ │ +└──────┴───────────────────┴─────────────┘ + 80dp weight(0.6f) weight(0.4f) +``` + +**Use cases:** +- Email: List + message detail +- Notes: List + editor +- Settings: Categories + options + +--- + +## Keyboard Navigation + +Desktop should support keyboard navigation. + +### Tab Navigation + +```kotlin +NavigationRail( + modifier = Modifier.focusable() +) { + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) }, + modifier = Modifier.focusable() + ) + // More items... +} +``` + +### Keyboard Shortcuts + +```kotlin +Window( + onPreviewKeyEvent = { event -> + when { + event.key == Key.One && event.isCtrlPressed -> + onScreenChange(AppScreen.Feed).also { true } + event.key == Key.Two && event.isCtrlPressed -> + onScreenChange(AppScreen.Search).also { true } + event.key == Key.Three && event.isCtrlPressed -> + onScreenChange(AppScreen.Messages).also { true } + else -> false + } + } +) { + // Content +} +``` + +**Standard:** +- Ctrl+1: First nav item (Feed) +- Ctrl+2: Second nav item (Search) +- Ctrl+3: Third nav item (Messages) +- Ctrl+Comma: Settings + +--- + +## Navigation Transitions + +### Desktop (Instant) + +No fancy animations. Instant switch. + +```kotlin +Box { + when (currentScreen) { + AppScreen.Feed -> FeedScreen() + AppScreen.Search -> SearchScreen() + } +} +``` + +### Android (Animated, Future) + +Can use Navigation Compose for transitions. + +```kotlin +NavHost(navController, startDestination = "feed") { + composable("feed") { FeedScreen() } + composable("search") { SearchScreen() } +} +``` + +--- + +## Best Practices + +### Desktop NavigationRail + +✅ **DO:** +- Keep width 72-80dp +- Show both icon and label +- Use Spacer.weight(1f) for bottom items +- Separate sections with HorizontalDivider +- Limit to 5-7 primary items + +❌ **DON'T:** +- Use bottom navigation on desktop +- Hide labels (plenty of space) +- Make it collapsible (not standard) +- Use hamburger menu (not desktop pattern) + +### Android NavigationBar + +✅ **DO:** +- Limit to 3-5 items +- Use bottom placement +- Consider label visibility on small screens +- Use standard icons + +❌ **DON'T:** +- Put more than 5 items +- Use top placement (deprecated) +- Put critical actions only in nav bar + +--- + +## Migration Strategy + +When adding Android support: + +1. **Extract shared state:** Move `AppScreen` to `commons/commonMain` +2. **Platform layouts:** Keep `NavigationRail` in `desktopApp/jvmMain`, `NavigationBar` in `amethyst/androidMain` +3. **Shared screens:** Composables in `commons/commonMain` (FeedScreen content) +4. **Platform chrome:** Navigation containers in platform modules + +**Example:** + +```kotlin +// commons/commonMain - Shared screen content +@Composable +fun FeedContent(notes: List) { + LazyColumn { + items(notes) { note -> + NoteCard(note) + } + } +} + +// desktopApp/jvmMain - Desktop wrapper +@Composable +fun FeedScreen() { + Column { + FeedHeader() // Desktop-specific header + FeedContent(notes) // Shared content + } +} + +// amethyst/androidMain - Android wrapper +@Composable +fun FeedScreen() { + Scaffold( + topBar = { TopAppBar { Text("Feed") } } + ) { + FeedContent(notes) // Same shared content + } +} +``` + +--- + +## References + +- **Current Desktop:** Main.kt:191-264 +- **Material3 NavigationRail:** [Material Design Docs](https://m3.material.io/components/navigation-rail) +- **Material3 NavigationBar:** [Material Design Docs](https://m3.material.io/components/navigation-bar) diff --git a/.claude/skills/desktop-expert/references/keyboard-shortcuts.md b/.claude/skills/desktop-expert/references/keyboard-shortcuts.md new file mode 100644 index 000000000..fd9257ef4 --- /dev/null +++ b/.claude/skills/desktop-expert/references/keyboard-shortcuts.md @@ -0,0 +1,400 @@ +# Keyboard Shortcuts Reference + +Standard keyboard shortcuts for desktop applications across macOS, Windows, and Linux. + +## Primary Modifier Keys + +| Platform | Primary | Secondary | Tertiary | +|----------|---------|-----------|----------| +| **macOS** | Cmd (⌘) / `meta` | Option (⌥) / `alt` | Ctrl (⌃) / `ctrl` | +| **Windows** | Ctrl / `ctrl` | Alt / `alt` | Win / `meta` | +| **Linux** | Ctrl / `ctrl` | Alt / `alt` | Super / `meta` | + +**In Compose Desktop:** + +```kotlin +// macOS +KeyShortcut(Key.N, meta = true) // Cmd+N + +// Windows/Linux +KeyShortcut(Key.N, ctrl = true) // Ctrl+N +``` + +--- + +## File Operations + +| Action | macOS | Windows | Linux | Notes | +|--------|-------|---------|-------|-------| +| **New** | Cmd+N | Ctrl+N | Ctrl+N | Create new | +| **Open** | Cmd+O | Ctrl+O | Ctrl+O | Open file | +| **Save** | Cmd+S | Ctrl+S | Ctrl+S | Save current | +| **Save As** | Cmd+Shift+S | Ctrl+Shift+S | Ctrl+Shift+S | Save with new name | +| **Close** | Cmd+W | Ctrl+W | Ctrl+W | Close window/tab | +| **Quit** | Cmd+Q | Ctrl+Q | Ctrl+Q | Exit app | +| **Print** | Cmd+P | Ctrl+P | Ctrl+P | Print | + +**Compose Implementation:** + +```kotlin +val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = if (isMacOS) { + KeyShortcut(Key.N, meta = true) + } else { + KeyShortcut(Key.N, ctrl = true) + }, + onClick = { createNewNote() } + ) + Item( + "Save", + shortcut = if (isMacOS) { + KeyShortcut(Key.S, meta = true) + } else { + KeyShortcut(Key.S, ctrl = true) + }, + onClick = { save() } + ) + Separator() + Item( + "Quit", + shortcut = if (isMacOS) { + KeyShortcut(Key.Q, meta = true) + } else { + KeyShortcut(Key.Q, ctrl = true) + }, + onClick = ::exitApplication + ) + } +} +``` + +--- + +## Edit Operations + +| Action | macOS | Windows | Linux | Notes | +|--------|-------|---------|-------|-------| +| **Undo** | Cmd+Z | Ctrl+Z | Ctrl+Z | Universal | +| **Redo** | Cmd+Shift+Z | Ctrl+Y | Ctrl+Y | Windows/Linux use Y | +| **Cut** | Cmd+X | Ctrl+X | Ctrl+X | Universal | +| **Copy** | Cmd+C | Ctrl+C | Ctrl+C | Universal | +| **Paste** | Cmd+V | Ctrl+V | Ctrl+V | Universal | +| **Select All** | Cmd+A | Ctrl+A | Ctrl+A | Universal | +| **Find** | Cmd+F | Ctrl+F | Ctrl+F | Search | +| **Find Next** | Cmd+G | F3 | F3 | Next result | +| **Replace** | Cmd+Option+F | Ctrl+H | Ctrl+H | Find & replace | + +**Note:** Undo/Redo typically handled by text fields automatically. + +--- + +## Navigation + +| Action | macOS | Windows | Linux | Notes | +|--------|-------|---------|-------|-------| +| **Tab 1** | Cmd+1 | Ctrl+1 | Ctrl+1 | First tab/view | +| **Tab 2** | Cmd+2 | Ctrl+2 | Ctrl+2 | Second tab/view | +| **Tab 3** | Cmd+3 | Ctrl+3 | Ctrl+3 | Third tab/view | +| **Next Tab** | Cmd+Option+→ | Ctrl+Tab | Ctrl+Tab | Cycle forward | +| **Prev Tab** | Cmd+Option+← | Ctrl+Shift+Tab | Ctrl+Shift+Tab | Cycle back | +| **Go Back** | Cmd+[ | Alt+← | Alt+← | Browser-style | +| **Go Forward** | Cmd+] | Alt+→ | Alt+→ | Browser-style | + +**Compose Implementation:** + +```kotlin +Window( + onPreviewKeyEvent = { event -> + if (event.type == KeyEventType.KeyDown) { + when { + event.key == Key.One && event.isPrimaryPressed() -> { + navigateTo(AppScreen.Feed) + true + } + event.key == Key.Two && event.isPrimaryPressed() -> { + navigateTo(AppScreen.Search) + true + } + event.key == Key.Three && event.isPrimaryPressed() -> { + navigateTo(AppScreen.Messages) + true + } + else -> false + } + } else false + } +) { + // Content +} + +// Helper extension +fun KeyEvent.isPrimaryPressed() = if (isMacOS) isMetaPressed else isCtrlPressed +``` + +--- + +## Window Management + +| Action | macOS | Windows | Linux | Notes | +|--------|-------|---------|-------|-------| +| **New Window** | Cmd+N | Ctrl+N | Ctrl+N | New instance | +| **Close Window** | Cmd+W | Alt+F4 | Alt+F4 | Close current | +| **Minimize** | Cmd+M | Win+Down | Super+Down | Minimize to dock/taskbar | +| **Maximize** | Cmd+Ctrl+F | Win+Up | Super+Up | Fullscreen/maximize | +| **Hide App** | Cmd+H | - | - | macOS only | +| **Switch Window** | Cmd+` | Alt+Tab | Alt+Tab | Between app windows | + +**Note:** Window management often handled by OS, not app shortcuts. + +--- + +## App-Specific (Amethyst) + +### Nostr Actions + +| Action | macOS | Windows | Linux | Description | +|--------|-------|---------|-------|-------------| +| **New Note** | Cmd+N | Ctrl+N | Ctrl+N | Compose new post | +| **Refresh Feed** | Cmd+R | Ctrl+R | Ctrl+R | Reload timeline | +| **Search** | Cmd+K | Ctrl+K | Ctrl+K | Quick search | +| **DMs** | Cmd+Shift+M | Ctrl+Shift+M | Ctrl+Shift+M | Open messages | +| **Settings** | Cmd+, | Ctrl+, | Ctrl+, | Open preferences | +| **Notifications** | Cmd+Shift+N | Ctrl+Shift+N | Ctrl+Shift+N | View alerts | + +**Implementation:** + +```kotlin +MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = DesktopShortcuts.primary(Key.N), + onClick = { showComposeDialog() } + ) + Item( + "Settings", + shortcut = DesktopShortcuts.primary(Key.Comma), + onClick = { navigateTo(AppScreen.Settings) } + ) + } + Menu("View") { + Item( + "Refresh Feed", + shortcut = DesktopShortcuts.primary(Key.R), + onClick = { refreshFeed() } + ) + Item( + "Search", + shortcut = DesktopShortcuts.primary(Key.K), + onClick = { focusSearch() } + ) + } +} +``` + +--- + +## Accessibility + +| Action | macOS | Windows | Linux | Description | +|--------|-------|---------|-------|-------------| +| **Zoom In** | Cmd++ | Ctrl++ | Ctrl++ | Increase size | +| **Zoom Out** | Cmd+- | Ctrl+- | Ctrl+- | Decrease size | +| **Reset Zoom** | Cmd+0 | Ctrl+0 | Ctrl+0 | Default size | +| **Help** | Cmd+? | F1 | F1 | Show help | + +--- + +## Best Practices + +### 1. OS-Aware Helper + +Create a utility for OS detection: + +```kotlin +// commons/src/jvmMain/kotlin/utils/PlatformShortcuts.kt +object DesktopShortcuts { + private val isMacOS = System.getProperty("os.name") + .lowercase() + .contains("mac") + + fun primary(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + } + + fun primaryShift(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, shift = true) + } else { + KeyShortcut(key, ctrl = true, shift = true) + } + + fun primaryAlt(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, alt = true) + } else { + KeyShortcut(key, ctrl = true, alt = true) + } + + val modifierName = if (isMacOS) "Cmd" else "Ctrl" + val secondaryName = if (isMacOS) "Option" else "Alt" +} +``` + +**Usage:** + +```kotlin +Item( + "Save", + shortcut = DesktopShortcuts.primary(Key.S), + onClick = { save() } +) +``` + +### 2. Show Shortcuts in Tooltips + +```kotlin +IconButton( + onClick = { refresh() }, + modifier = Modifier.tooltipArea { + Text("Refresh (${DesktopShortcuts.modifierName}+R)") + } +) { + Icon(Icons.Default.Refresh, "Refresh") +} +``` + +### 3. Shortcuts Menu + +Provide a "Keyboard Shortcuts" help menu: + +```kotlin +Menu("Help") { + Item("Keyboard Shortcuts", onClick = { showShortcutsDialog() }) +} + +// Dialog content +@Composable +fun ShortcutsDialog() { + Dialog(onDismissRequest = { /* close */ }) { + Surface { + Column(Modifier.padding(16.dp)) { + Text("Keyboard Shortcuts", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(16.dp)) + + ShortcutRow("New Note", "${DesktopShortcuts.modifierName}+N") + ShortcutRow("Save", "${DesktopShortcuts.modifierName}+S") + ShortcutRow("Search", "${DesktopShortcuts.modifierName}+K") + ShortcutRow("Settings", "${DesktopShortcuts.modifierName}+,") + // ... + } + } + } +} + +@Composable +fun ShortcutRow(action: String, shortcut: String) { + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(action, style = MaterialTheme.typography.bodyMedium) + Text( + shortcut, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} +``` + +### 4. Avoid Conflicts + +**Check for OS-level shortcuts:** + +| macOS Reserved | Description | +|----------------|-------------| +| Cmd+Tab | Switch apps | +| Cmd+Space | Spotlight | +| Cmd+H | Hide window | +| Cmd+M | Minimize | +| Cmd+Q | Quit | +| Cmd+W | Close window | + +**Windows Reserved:** + +| Windows Reserved | Description | +|-----------------|-------------| +| Win+D | Show desktop | +| Win+E | File Explorer | +| Win+L | Lock screen | +| Alt+Tab | Switch apps | +| Alt+F4 | Close window | + +**Don't override these unless critical.** + +--- + +## Testing Shortcuts + +```kotlin +// Test OS detection +@Test +fun testOsDetection() { + val osName = System.getProperty("os.name") + println("OS: $osName") + + val isMacOS = osName.lowercase().contains("mac") + println("Is macOS: $isMacOS") + + val shortcut = if (isMacOS) { + KeyShortcut(Key.N, meta = true) + } else { + KeyShortcut(Key.N, ctrl = true) + } + + println("Primary modifier for New: $shortcut") +} +``` + +--- + +## Current Issues in Amethyst + +**Main.kt:105-123** hardcodes `ctrl = true`: + +```kotlin +// ❌ WRONG: Hardcoded Ctrl (doesn't work on macOS) +Item( + "New Note", + shortcut = KeyShortcut(Key.N, ctrl = true), // Should be Cmd on macOS + onClick = { /* ... */ } +) +``` + +**Fix:** + +```kotlin +// ✅ CORRECT: OS-aware +Item( + "New Note", + shortcut = DesktopShortcuts.primary(Key.N), + onClick = { /* ... */ } +) +``` + +--- + +## References + +- [macOS Keyboard Shortcuts](https://support.apple.com/en-us/102650) +- [Windows Keyboard Shortcuts](https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec) +- [GNOME Keyboard Shortcuts](https://help.gnome.org/users/gnome-help/stable/shell-keyboard-shortcuts.html) +- [Material Design: Keyboard Shortcuts](https://m3.material.io/foundations/interaction/keyboard) +- [Compose Desktop: Keyboard Events](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-keyboard.html) diff --git a/.claude/skills/desktop-expert/references/os-detection.md b/.claude/skills/desktop-expert/references/os-detection.md new file mode 100644 index 000000000..0330308f5 --- /dev/null +++ b/.claude/skills/desktop-expert/references/os-detection.md @@ -0,0 +1,579 @@ +# OS Detection & Platform-Specific Code + +Patterns for detecting operating system and implementing platform-specific behavior in Compose Desktop. + +## OS Detection + +### Basic Detection + +```kotlin +val osName = System.getProperty("os.name").lowercase() + +val isMacOS = osName.contains("mac") +val isWindows = osName.contains("win") +val isLinux = osName.contains("nux") || osName.contains("nix") +``` + +### System Properties + +```kotlin +// OS name +System.getProperty("os.name") +// Examples: "Mac OS X", "Windows 10", "Linux" + +// OS version +System.getProperty("os.version") +// Examples: "14.2.1", "10.0", "6.5.0-14-generic" + +// OS architecture +System.getProperty("os.arch") +// Examples: "aarch64", "x86_64", "amd64" + +// User home directory +System.getProperty("user.home") +// Examples: "/Users/username", "C:\Users\username", "/home/username" + +// File separator +System.getProperty("file.separator") +// Examples: "/" (Unix), "\" (Windows) + +// Path separator +System.getProperty("path.separator") +// Examples: ":" (Unix), ";" (Windows) +``` + +--- + +## PlatformDetector Utility + +Create a centralized utility for platform detection. + +**File:** `commons/src/jvmMain/kotlin/utils/PlatformDetector.kt` + +```kotlin +package com.vitorpamplona.amethyst.commons.utils + +object PlatformDetector { + private val osName = System.getProperty("os.name").lowercase() + + val isMacOS: Boolean = osName.contains("mac") + val isWindows: Boolean = osName.contains("win") + val isLinux: Boolean = osName.contains("nux") || osName.contains("nix") + + val platform: Platform = when { + isMacOS -> Platform.MacOS + isWindows -> Platform.Windows + isLinux -> Platform.Linux + else -> Platform.Unknown + } + + enum class Platform { + MacOS, + Windows, + Linux, + Unknown + } + + // File paths + val fileSeparator: String = System.getProperty("file.separator") + val pathSeparator: String = System.getProperty("path.separator") + + // User directories + val userHome: String = System.getProperty("user.home") + + val appDataDir: String = when (platform) { + Platform.MacOS -> "$userHome/Library/Application Support" + Platform.Windows -> System.getenv("APPDATA") ?: "$userHome\\AppData\\Roaming" + Platform.Linux -> System.getenv("XDG_CONFIG_HOME") ?: "$userHome/.config" + Platform.Unknown -> userHome + } + + // Modifier key names + val primaryModifierName: String = if (isMacOS) "Cmd" else "Ctrl" + val secondaryModifierName: String = if (isMacOS) "Option" else "Alt" + + fun platformSpecific( + macOS: () -> Unit = {}, + windows: () -> Unit = {}, + linux: () -> Unit = {}, + fallback: () -> Unit = {} + ) { + when (platform) { + Platform.MacOS -> macOS() + Platform.Windows -> windows() + Platform.Linux -> linux() + Platform.Unknown -> fallback() + } + } +} +``` + +**Usage:** + +```kotlin +// Simple check +if (PlatformDetector.isMacOS) { + // macOS-specific code +} + +// Pattern matching +when (PlatformDetector.platform) { + Platform.MacOS -> setupMacDock() + Platform.Windows -> setupWindowsTray() + Platform.Linux -> setupLinuxTray() + Platform.Unknown -> showWarning() +} + +// Platform-specific execution +PlatformDetector.platformSpecific( + macOS = { setupMacMenuBar() }, + windows = { setupWindowsMenu() }, + linux = { setupLinuxMenu() } +) + +// File paths +val configPath = "${PlatformDetector.appDataDir}${PlatformDetector.fileSeparator}amethyst" +``` + +--- + +## Platform-Specific UI + +### Keyboard Shortcuts Helper + +```kotlin +package com.vitorpamplona.amethyst.commons.utils + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut + +object DesktopShortcuts { + private val isMacOS = PlatformDetector.isMacOS + + fun primary(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + } + + fun primaryShift(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, shift = true) + } else { + KeyShortcut(key, ctrl = true, shift = true) + } + + fun primaryAlt(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, alt = true) + } else { + KeyShortcut(key, ctrl = true, alt = true) + } + + val modifierName = PlatformDetector.primaryModifierName + val secondaryName = PlatformDetector.secondaryModifierName + + fun formatShortcut(key: String, withPrimary: Boolean = true): String { + return if (withPrimary) "$modifierName+$key" else key + } +} +``` + +### File Paths Helper + +```kotlin +package com.vitorpamplona.amethyst.commons.utils + +import java.io.File + +object FilePaths { + private val separator = PlatformDetector.fileSeparator + + fun join(vararg parts: String): String { + return parts.joinToString(separator) + } + + fun appConfig(appName: String): String { + return join(PlatformDetector.appDataDir, appName) + } + + fun appCache(appName: String): String { + return when (PlatformDetector.platform) { + PlatformDetector.Platform.MacOS -> + join(PlatformDetector.userHome, "Library", "Caches", appName) + PlatformDetector.Platform.Windows -> + join(System.getenv("LOCALAPPDATA") ?: "${PlatformDetector.userHome}\\AppData\\Local", appName) + PlatformDetector.Platform.Linux -> + join(System.getenv("XDG_CACHE_HOME") ?: "${PlatformDetector.userHome}/.cache", appName) + else -> join(PlatformDetector.userHome, ".cache", appName) + } + } + + fun ensureDirectory(path: String): File { + return File(path).apply { + if (!exists()) { + mkdirs() + } + } + } +} + +// Usage +val configDir = FilePaths.ensureDirectory(FilePaths.appConfig("amethyst")) +val cacheDir = FilePaths.ensureDirectory(FilePaths.appCache("amethyst")) +``` + +--- + +## Platform-Specific Features + +### Open External URL + +```kotlin +// commons/src/commonMain/kotlin/utils/ExternalUrl.kt +expect fun openExternalUrl(url: String) + +// commons/src/jvmMain/kotlin/utils/ExternalUrl.jvm.kt +import java.awt.Desktop +import java.net.URI + +actual fun openExternalUrl(url: String) { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI(url)) + } +} +``` + +### File Picker + +```kotlin +// Platform-specific file picker +fun showFilePicker( + title: String = "Select file", + mode: FilePickerMode = FilePickerMode.Load +): String? { + val fileDialog = java.awt.FileDialog( + java.awt.Frame(), + title, + when (mode) { + FilePickerMode.Load -> java.awt.FileDialog.LOAD + FilePickerMode.Save -> java.awt.FileDialog.SAVE + } + ) + + // macOS-specific: Enable file selection features + if (PlatformDetector.isMacOS) { + System.setProperty("apple.awt.fileDialogForDirectories", "false") + } + + fileDialog.isVisible = true + + return fileDialog.file?.let { "${fileDialog.directory}$it" } +} + +enum class FilePickerMode { + Load, + Save +} +``` + +### Directory Picker (macOS) + +```kotlin +fun showDirectoryPicker(title: String = "Select directory"): String? { + if (PlatformDetector.isMacOS) { + // macOS-specific directory picker + System.setProperty("apple.awt.fileDialogForDirectories", "true") + } + + val fileDialog = java.awt.FileDialog(java.awt.Frame(), title, java.awt.FileDialog.LOAD) + fileDialog.isVisible = true + + if (PlatformDetector.isMacOS) { + System.setProperty("apple.awt.fileDialogForDirectories", "false") + } + + return fileDialog.directory +} +``` + +--- + +## Window Decorations + +### macOS-Specific + +```kotlin +// Unified title bar (macOS Big Sur+) +if (PlatformDetector.isMacOS) { + Window( + undecorated = false, + transparent = true, + // ... + ) { + // Custom title bar + } +} +``` + +### Windows-Specific + +```kotlin +// Custom window chrome (Windows) +if (PlatformDetector.isWindows) { + Window( + undecorated = true, + // Custom decorations + ) { + Column { + // Custom title bar with min/max/close buttons + WindowTitleBar() + // Content + } + } +} +``` + +--- + +## System Tray Icons + +Different icon formats per OS: + +```kotlin +fun getTrayIcon(): Painter { + return when (PlatformDetector.platform) { + Platform.MacOS -> painterResource("tray-icon-mac.png") // Template icon + Platform.Windows -> painterResource("tray-icon-win.ico") + Platform.Linux -> painterResource("tray-icon-linux.png") + else -> painterResource("tray-icon.png") + } +} + +// macOS: Template icons (black/transparent) +// Windows: ICO format, 16x16 +// Linux: PNG, typically 24x24 +``` + +--- + +## Native Notifications + +```kotlin +// Platform-specific notification implementation +fun sendNotification(title: String, message: String) { + PlatformDetector.platformSpecific( + macOS = { + // macOS: Use NSUserNotification (via tray) + trayState.sendNotification( + Notification(title, message, Notification.Type.Info) + ) + }, + windows = { + // Windows: Use Windows toast notifications + trayState.sendNotification( + Notification(title, message, Notification.Type.Info) + ) + }, + linux = { + // Linux: Use libnotify (via tray) + trayState.sendNotification( + Notification(title, message, Notification.Type.Info) + ) + } + ) +} +``` + +--- + +## Architecture Detection + +```kotlin +object ArchDetector { + private val arch = System.getProperty("os.arch").lowercase() + + val isArm: Boolean = arch.contains("aarch") || arch.contains("arm") + val isX64: Boolean = arch.contains("x86_64") || arch.contains("amd64") + val isX86: Boolean = arch.contains("x86") && !isX64 + + val architecture: Architecture = when { + isArm -> Architecture.ARM + isX64 -> Architecture.X64 + isX86 -> Architecture.X86 + else -> Architecture.Unknown + } + + enum class Architecture { + ARM, + X64, + X86, + Unknown + } +} + +// Usage: Load correct native library +fun loadNativeLib() { + val libName = when { + PlatformDetector.isMacOS && ArchDetector.isArm -> "libsecp256k1-macos-arm64" + PlatformDetector.isMacOS && ArchDetector.isX64 -> "libsecp256k1-macos-x64" + PlatformDetector.isWindows && ArchDetector.isX64 -> "libsecp256k1-win-x64" + PlatformDetector.isLinux && ArchDetector.isX64 -> "libsecp256k1-linux-x64" + else -> throw UnsupportedOperationException("Unsupported platform") + } + + System.loadLibrary(libName) +} +``` + +--- + +## Testing Platform Detection + +```kotlin +@Test +fun testPlatformDetection() { + println("OS: ${System.getProperty("os.name")}") + println("Version: ${System.getProperty("os.version")}") + println("Arch: ${System.getProperty("os.arch")}") + println() + println("Is macOS: ${PlatformDetector.isMacOS}") + println("Is Windows: ${PlatformDetector.isWindows}") + println("Is Linux: ${PlatformDetector.isLinux}") + println("Platform: ${PlatformDetector.platform}") + println() + println("User home: ${PlatformDetector.userHome}") + println("App data: ${PlatformDetector.appDataDir}") + println("File separator: ${PlatformDetector.fileSeparator}") +} + +// Example output (macOS): +// OS: Mac OS X +// Version: 14.2.1 +// Arch: aarch64 +// +// Is macOS: true +// Is Windows: false +// Is Linux: false +// Platform: MacOS +// +// User home: /Users/username +// App data: /Users/username/Library/Application Support +// File separator: / +``` + +--- + +## Best Practices + +### 1. Centralize Detection + +✅ **DO:** Use PlatformDetector singleton +```kotlin +if (PlatformDetector.isMacOS) { /* ... */ } +``` + +❌ **DON'T:** Repeat detection everywhere +```kotlin +if (System.getProperty("os.name").lowercase().contains("mac")) { /* ... */ } +``` + +### 2. Use expect/actual for Platform APIs + +```kotlin +// commonMain +expect fun openFile(path: String) + +// jvmMain (Desktop) +actual fun openFile(path: String) { + Desktop.getDesktop().open(File(path)) +} + +// androidMain +actual fun openFile(path: String) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(path))) +} +``` + +### 3. Graceful Degradation + +```kotlin +fun openBrowser(url: String) { + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI(url)) + } else { + // Fallback: Copy to clipboard + Toolkit.getDefaultToolkit().systemClipboard.setContents( + StringSelection(url), + null + ) + showMessage("URL copied to clipboard: $url") + } + } catch (e: Exception) { + showError("Failed to open browser: ${e.message}") + } +} +``` + +### 4. Test on All Platforms + +Always test platform-specific code on: +- macOS (Intel + Apple Silicon if possible) +- Windows (10/11) +- Linux (Ubuntu/Fedora) + +--- + +## Common Patterns + +### Pattern: Config File Location + +```kotlin +fun getConfigFile(filename: String): File { + val configDir = when (PlatformDetector.platform) { + Platform.MacOS -> + File("${PlatformDetector.userHome}/Library/Application Support/Amethyst") + Platform.Windows -> + File("${System.getenv("APPDATA")}\\Amethyst") + Platform.Linux -> + File("${PlatformDetector.userHome}/.config/amethyst") + else -> + File("${PlatformDetector.userHome}/.amethyst") + } + + if (!configDir.exists()) { + configDir.mkdirs() + } + + return File(configDir, filename) +} + +// Usage +val settingsFile = getConfigFile("settings.json") +``` + +### Pattern: Platform-Specific Resources + +```kotlin +fun getPlatformIcon(name: String): Painter { + val extension = when (PlatformDetector.platform) { + Platform.MacOS -> "icns" + Platform.Windows -> "ico" + else -> "png" + } + + return painterResource("$name.$extension") +} + +// Resources: +// src/jvmMain/resources/app-icon.icns (macOS) +// src/jvmMain/resources/app-icon.ico (Windows) +// src/jvmMain/resources/app-icon.png (Linux) +``` + +--- + +## References + +- [System Properties (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/System.html#getProperties()) +- [Desktop API (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Desktop.html) +- [File System Standards (XDG)](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) diff --git a/.claude/skills/gradle-expert/SKILL.md b/.claude/skills/gradle-expert/SKILL.md new file mode 100644 index 000000000..208caa11c --- /dev/null +++ b/.claude/skills/gradle-expert/SKILL.md @@ -0,0 +1,548 @@ +--- +name: gradle-expert +description: Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencies and source sets, (5) Desktop packaging (DMG/MSI/DEB), (6) Build performance optimization, (7) Proguard/R8 configuration, (8) Common KMP + Android Gradle issues (Compose conflicts, secp256k1 JNI variants, source set problems). +--- + +# Gradle Expert + +Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations. + +## Build Architecture Mental Model + +Think of this project as **4 layers**: + +``` +┌─────────────┬─────────────┐ +│ :amethyst │ :desktopApp │ ← Platform apps (navigation, layouts) +│ (Android) │ (JVM) │ +└──────┬──────┴──────┬──────┘ + │ │ + └──────┬──────┘ + ▼ + ┌─────────────┐ + │ :commons │ ← Shared UI (KMP with jvmAndroid) + │ (KMP UI) │ + └──────┬──────┘ + ▼ + ┌─────────────┐ + │ :quartz │ ← Core library (KMP: Android/JVM/iOS) + │(KMP Library)│ + └─────────────┘ +``` + +**Key insight:** Dependencies flow DOWN. Lower modules never depend on upper modules. This enables code sharing without circular dependencies. + +**The jvmAndroid pattern:** Unique to this project. A custom source set between commonMain and {androidMain, jvmMain} for JVM-specific code shared by Android and Desktop. Not standard KMP, but critical for this architecture. + +## Version Catalog Philosophy + +All dependencies centralized in `gradle/libs.versions.toml`. Think "single source of truth." + +**Pattern:** +```toml +[versions] +kotlin = "2.3.0" + +[libraries] +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } + +[plugins] +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +``` + +**Usage:** +```kotlin +dependencies { + implementation(libs.okhttp) // Type-safe, IDE-autocompleted +} +``` + +**Critical alignments:** +- **Kotlin ecosystem:** All Kotlin plugins MUST share same version +- **Compose ecosystem:** Compose Multiplatform version → Kotlin version (check compatibility matrix) +- **secp256k1 variants:** All three variants (common, jni-android, jni-jvm) MUST share same version + +See [references/version-catalog-guide.md](references/version-catalog-guide.md) for comprehensive patterns. + +## Common Build Tasks + +### Quick Reference + +```bash +# Full builds +./gradlew build # All modules +./gradlew clean build # Clean build + +# Desktop +./gradlew :desktopApp:run # Run desktop app +./gradlew :desktopApp:packageDmg # macOS package + +# Module-specific +./gradlew :quartz:build # KMP library only +./gradlew :commons:build # Shared UI only + +# Analysis +./gradlew dependencies # Dependency tree +./gradlew build --scan # Online diagnostics +``` + +See [references/build-commands.md](references/build-commands.md) for comprehensive command reference. + +## Module Structure & Dependencies + +### Dependency Flow + +**Desktop build chain:** +``` +:desktopApp → :commons (jvmMain) → :quartz (jvmMain → jvmAndroid → commonMain) +``` + +**Android build chain:** +``` +:amethyst → :commons (androidMain) → :quartz (androidMain → jvmAndroid → commonMain) +``` + +**Key source set pattern (quartz & commons):** +``` +commonMain # Truly cross-platform code + │ + ├─ jvmAndroid # JVM-specific, shared by Android + Desktop + │ ├─ androidMain + │ └─ jvmMain + │ + └─ iosMain # iOS-specific (quartz only) +``` + +**Dependency config types:** +- Use `api` when types appear in module's public API or expect/actual declarations +- Use `implementation` for internal implementation details +- Example: quartz exposes secp256k1 (`api`), but hides okhttp (`implementation`) + +See [references/dependency-graph.md](references/dependency-graph.md) for module visualization and transitive dependency flow. + +## Critical Dependency Patterns + +### 1. secp256k1 (Crypto Library) + +**The problem:** KMP library with platform-specific JNI bindings. Wrong variant = runtime crash. + +**Pattern:** +```kotlin +// commonMain - API only +api(libs.secp256k1.kmp.common) + +// androidMain - Android JNI +api(libs.secp256k1.kmp.jni.android) + +// jvmMain - Desktop JVM JNI +implementation(libs.secp256k1.kmp.jni.jvm) +``` + +**Why api in androidMain?** Types leak to consumers (:amethyst). + +**Common error:** Desktop using jni-android variant → `UnsatisfiedLinkError: no secp256k1jni in java.library.path` + +**Fix:** Check source set dependencies. jvmMain must use jni-jvm, never jni-android. + +### 2. JNA (for LibSodium Encryption) + +**The problem:** Android needs AAR packaging, JVM needs JAR. Same library, different artifact types. + +**Pattern:** +```kotlin +// androidMain +implementation("com.goterl:lazysodium-android:5.2.0@aar") // @aar explicit +implementation("net.java.dev.jna:jna:5.18.1@aar") + +// jvmMain +implementation(libs.lazysodium.java) // JAR implicit +implementation(libs.jna) +``` + +**Critical:** Never put JNA in jvmAndroid or commonMain. Platform-specific packaging only. + +### 3. Compose Versions + +**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes. + +**Current project config:** +```toml +composeMultiplatform = "1.9.3" # Plugin + runtime +composeBom = "2025.12.01" # AndroidX Compose BOM +kotlin = "2.3.0" +``` + +**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html + +**In KMP modules (quartz, commons):** +```kotlin +// ✅ Use Compose Multiplatform +implementation(compose.ui) +implementation(compose.material3) + +// ❌ DON'T use AndroidX BOM in KMP modules +// implementation(libs.androidx.compose.bom) +``` + +**In Android-only modules (amethyst):** +```kotlin +// Can use AndroidX BOM +val composeBom = platform(libs.androidx.compose.bom) +implementation(composeBom) +``` + +## Desktop Packaging Basics + +**TargetFormat options:** +```kotlin +// In desktopApp/build.gradle.kts +nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + + packageName = "Amethyst" + packageVersion = "1.0.0" + + macOS { + bundleID = "com.vitorpamplona.amethyst.desktop" + iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + } +} +``` + +**Package tasks:** +```bash +./gradlew :desktopApp:packageDmg # macOS +./gradlew :desktopApp:packageMsi # Windows +./gradlew :desktopApp:packageDeb # Linux +``` + +**Output locations:** +- macOS: `desktopApp/build/compose/binaries/main/dmg/` +- Windows: `desktopApp/build/compose/binaries/main/msi/` +- Linux: `desktopApp/build/compose/binaries/main/deb/` + +**Icon requirements:** +- macOS: `.icns` (multi-resolution: 512, 256, 128, 32) +- Windows: `.ico` (256, 128, 64, 32, 16) +- Linux: `.png` (512x512) + +**Common issues:** +- Main class not found → Verify `mainClass = "...MainKt"` (Kotlin adds `Kt` suffix) +- Native libs missing → Ensure secp256k1-kmp-jni-jvm in dependencies +- Icon not found → Check file exists at path, use absolute path if needed + +## Build Performance Optimization + +**Add to `gradle.properties`:** +```properties +# Daemon (faster subsequent builds) +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g + +# Parallel execution (multi-module speedup) +org.gradle.parallel=true +org.gradle.workers.max=8 + +# Caching (incremental builds) +org.gradle.caching=true +org.gradle.configuration-cache=true + +# Kotlin daemon +kotlin.incremental=true +kotlin.daemon.jvmargs=-Xmx2g +``` + +**Impact:** Typically 30-50% faster builds after first run. + +**Measure impact:** +```bash +./gradlew clean build --profile +# Report: build/reports/profile/profile-.html +``` + +**When to clean build:** +- After changing version catalog +- After adding/removing source sets +- When seeing unexplained errors + +**When NOT to clean:** +- Regular development iteration +- Small code changes +- Incremental compilation works fine + +Use script: `scripts/analyze-build-time.sh` for automated profiling. + +## Troubleshooting: Practical Patterns + +### Pattern 1: Version Conflict + +**Symptom:** `Duplicate class` or `NoSuchMethodError` + +**Diagnosis:** +```bash +./gradlew dependencyInsight --dependency +``` + +**Fix options:** +1. Align versions in libs.versions.toml (preferred) +2. Force resolution: +```kotlin +configurations.all { + resolutionStrategy { + force(libs.okhttp.get().toString()) + } +} +``` + +### Pattern 2: Source Set Issues + +**Symptom:** `Unresolved reference` to JVM library in shared code + +**Diagnosis:** Check source set hierarchy. JVM-only libs (jackson, okhttp) can't be in commonMain. + +**Fix:** Move to jvmAndroid or platform-specific source set. + +```kotlin +// ❌ Wrong +commonMain { + dependencies { + implementation(libs.jackson.module.kotlin) // JVM-only! + } +} + +// ✅ Correct +val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + dependencies { + api(libs.jackson.module.kotlin) // JVM code, shared by Android + Desktop + } +} +``` + +### Pattern 3: Proguard Stripping Native Libs + +**Symptom:** `NoClassDefFoundError` for secp256k1, JNA, or LibSodium in release builds + +**Fix:** Update proguard rules in `quartz/proguard-rules.pro`: +```proguard +# Native libraries +-keep class fr.acinq.secp256k1.** { *; } +-keep class com.goterl.lazysodium.** { *; } +-keep class com.sun.jna.** { *; } + +# Jackson (reflection-based) +-keep class com.vitorpamplona.quartz.** { *; } +-keepattributes *Annotation* +-keepattributes Signature +``` + +### Pattern 4: Compose Compiler Mismatch + +**Symptom:** `IllegalStateException: Version mismatch: runtime 1.10.0 but compiler 1.9.0` + +**Fix:** Update Compose Multiplatform version in libs.versions.toml to match Kotlin version compatibility. + +Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html + +### Pattern 5: Wrong JVM Target + +**Symptom:** `Unsupported class file major version 65` + +**Fix:** Ensure Java 21 everywhere: +```bash +# Check current Java +java -version # Should show 21 + +# Set JAVA_HOME +export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home + +# Stop Gradle daemon to pick up new Java +./gradlew --stop +``` + +Verify all build files use JVM 21: +```kotlin +kotlin { + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } +} + +android { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} +``` + +## Comprehensive Error Guide + +For detailed troubleshooting of specific errors, see [references/common-errors.md](references/common-errors.md). Covers: +- Compose version conflicts +- secp256k1 JNI errors +- Source set dependency issues +- Proguard/R8 problems +- Desktop packaging errors +- Kotlin compilation errors +- Dependency resolution failures +- JVM/JDK version issues + +Each error includes: symptom, cause, solution, verification steps. + +## Quick Diagnostic Commands + +```bash +# Check dependencies for specific module +./gradlew :quartz:dependencies + +# Find specific library in dependency tree +./gradlew dependencyInsight --dependency okhttp + +# Build with detailed logging +./gradlew build --info + +# Generate interactive build scan (best diagnostics) +./gradlew build --scan + +# Profile build performance +./gradlew clean build --profile + +# Stop all Gradle daemons (fresh start) +./gradlew --stop + +# Check Gradle version +./gradlew --version +``` + +## Scripts & References + +### Diagnostic Scripts +- `scripts/analyze-build-time.sh` - Profile build performance, generate optimization report +- `scripts/fix-dependency-conflicts.sh` - Diagnose common dependency conflicts, suggest fixes + +### Reference Docs +- `references/build-commands.md` - Comprehensive command reference for all tasks +- `references/dependency-graph.md` - Module dependencies, source set hierarchy, transitive deps +- `references/version-catalog-guide.md` - Version catalog patterns, usage, best practices +- `references/common-errors.md` - Troubleshooting guide for frequent build issues + +## Workflow Examples + +### Example 1: Adding New Dependency + +**Task:** Add kotlinx.datetime to quartz + +**Steps:** +1. **Update version catalog** (gradle/libs.versions.toml): +```toml +[versions] +kotlinxDatetime = "0.6.0" + +[libraries] +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } +``` + +2. **Add to build file** (quartz/build.gradle.kts): +```kotlin +sourceSets { + commonMain { + dependencies { + implementation(libs.kotlinx.datetime) // KMP library, goes in commonMain + } + } +} +``` + +3. **Sync & verify:** +```bash +./gradlew :quartz:dependencies | grep datetime +``` + +### Example 2: Fixing secp256k1 Error on Desktop + +**Error:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path` when running desktop app + +**Diagnosis:** +```bash +./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1 +# Shows: secp256k1-kmp-jni-android ← WRONG! +``` + +**Fix:** +```kotlin +// In quartz/build.gradle.kts +jvmMain { + dependencies { + // Change from: + // implementation(libs.secp256k1.kmp.jni.android) ❌ + + // To: + implementation(libs.secp256k1.kmp.jni.jvm) // ✅ + } +} +``` + +**Verify:** +```bash +./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1 +# Now shows: secp256k1-kmp-jni-jvm ✅ + +./gradlew :desktopApp:run # Should work +``` + +### Example 3: Optimizing Build Time + +**Current:** Clean build takes 5 minutes + +**Steps:** +1. **Baseline measurement:** +```bash +./gradlew clean build --profile +# Check: build/reports/profile/profile-*.html +``` + +2. **Add optimizations** to gradle.properties: +```properties +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.jvmargs=-Xmx4g +kotlin.incremental=true +``` + +3. **Re-measure:** +```bash +./gradlew clean build --profile +``` + +**Expected improvement:** 30-50% faster on subsequent builds (incremental builds much faster). + +## Delegation Patterns + +**When to delegate to other skills:** +- **Source set architecture** (jvmAndroid pattern, expect/actual) → Use `kotlin-multiplatform` skill +- **Compose UI issues** (composables, state management) → Use `compose-expert` skill (when available) +- **Kotlin language issues** (Flow, sealed classes, DSLs) → Use `kotlin-expert` skill +- **Desktop-specific features** (Window management, MenuBar, tray) → Use `desktop-expert` skill + +**This skill handles:** Build system, dependencies, versioning, module structure, packaging, performance. + +## Core Principles for This Build System + +1. **Centralize versions:** Never hardcode versions in build.gradle.kts. Always use libs.versions.toml. + +2. **Respect source set hierarchy:** Dependencies flow downward. jvmAndroid depends on commonMain, never the reverse. + +3. **Platform-specific variants matter:** secp256k1, JNA must use correct variant per platform. Check when errors occur. + +4. **Clean builds are expensive:** Use incremental compilation. Only clean when truly needed (source set changes, version updates). + +5. **Compose alignment is critical:** Compose Multiplatform version must match Kotlin version. Check compatibility matrix. + +6. **Proguard for native libs:** All JNI libraries need explicit `-keep` rules in release builds. + +7. **Java 21 everywhere:** All modules, all targets, consistent JVM version. diff --git a/.claude/skills/gradle-expert/references/build-commands.md b/.claude/skills/gradle-expert/references/build-commands.md new file mode 100644 index 000000000..63cde3dfb --- /dev/null +++ b/.claude/skills/gradle-expert/references/build-commands.md @@ -0,0 +1,214 @@ +# Build Commands Reference + +## Table of Contents +- [Core Build Tasks](#core-build-tasks) +- [Module-Specific Builds](#module-specific-builds) +- [Desktop Tasks](#desktop-tasks) +- [Android Tasks](#android-tasks) +- [Testing](#testing) +- [Analysis & Diagnostics](#analysis--diagnostics) +- [Performance Optimization](#performance-optimization) + +## Core Build Tasks + +### Full Project Build +```bash +./gradlew build # Build all modules +./gradlew clean build # Clean build +./gradlew assemble # Build without tests +``` + +### Incremental Builds +```bash +./gradlew :quartz:build # Build only quartz module +./gradlew :commons:build # Build only commons module +./gradlew :desktopApp:build # Build only desktop app +``` + +## Module-Specific Builds + +### Quartz (KMP Library) +```bash +./gradlew :quartz:build # All targets +./gradlew :quartz:compileKotlinJvm # JVM target only +./gradlew :quartz:compileDebugKotlinAndroid # Android target only +./gradlew :quartz:linkDebugFrameworkIosArm64 # iOS framework +./gradlew :quartz:publishToMavenLocal # Publish locally +``` + +### Commons (Shared UI) +```bash +./gradlew :commons:build # All targets +./gradlew :commons:compileKotlinJvm # Desktop target +./gradlew :commons:compileDebugKotlinAndroid # Android target +``` + +## Desktop Tasks + +### Run Desktop App +```bash +./gradlew :desktopApp:run # Run desktop app +./gradlew :desktopApp:runDistributable # Run packaged version +``` + +### Package Desktop App +```bash +./gradlew :desktopApp:createDistributable # Create runnable package +./gradlew :desktopApp:packageDmg # macOS DMG +./gradlew :desktopApp:packageMsi # Windows MSI +./gradlew :desktopApp:packageDeb # Linux DEB +``` + +### Distribution Location +- macOS: `desktopApp/build/compose/binaries/main/dmg/` +- Windows: `desktopApp/build/compose/binaries/main/msi/` +- Linux: `desktopApp/build/compose/binaries/main/deb/` + +## Android Tasks + +### Compile & Assemble +```bash +./gradlew :amethyst:assembleDebug # Debug APK +./gradlew :amethyst:assembleRelease # Release APK +./gradlew :amethyst:bundleRelease # Release AAB +``` + +### Install & Run +```bash +./gradlew :amethyst:installDebug # Install debug on device +adb shell am start -n com.vitorpamplona.amethyst/.MainActivity +``` + +### Proguard/R8 +```bash +./gradlew :quartz:minifyReleaseWithR8 # Test R8 minification +``` + +## Testing + +### Unit Tests +```bash +./gradlew test # All unit tests +./gradlew :quartz:jvmTest # JVM unit tests +./gradlew :quartz:testDebugUnitTest # Android unit tests +./gradlew :commons:test # Commons tests +``` + +### Android Instrumented Tests +```bash +./gradlew :quartz:connectedAndroidTest # Requires device/emulator +``` + +### Test Reports +```bash +# Reports location: /build/reports/tests/ +open quartz/build/reports/tests/jvmTest/index.html +``` + +## Analysis & Diagnostics + +### Dependency Analysis +```bash +./gradlew dependencies # All dependencies +./gradlew :quartz:dependencies # Quartz dependencies +./gradlew dependencyInsight --dependency okhttp # Specific dependency +``` + +### Build Scan +```bash +./gradlew build --scan # Upload to scans.gradle.com +``` + +### Performance Profiling +```bash +./gradlew build --profile # Generate profile report +# Report: build/reports/profile/profile-.html +``` + +### Task Dependencies +```bash +./gradlew :desktopApp:run --dry-run # Show task graph +./gradlew :desktopApp:dependencies --scan # Visualize dependencies +``` + +## Performance Optimization + +### Configuration Cache +```bash +./gradlew build --configuration-cache # Enable config cache +./gradlew build --configuration-cache-problems=warn +``` + +### Build Cache +```bash +./gradlew build --build-cache # Enable build cache +./gradlew cleanBuildCache # Clear build cache +``` + +### Parallel Execution +```bash +./gradlew build --parallel --max-workers=8 # Parallel with 8 workers +``` + +### Daemon Management +```bash +./gradlew --stop # Stop Gradle daemon +./gradlew --status # Daemon status +``` + +### Incremental Compilation +```bash +# Already enabled by default in Kotlin, but can verify: +./gradlew :quartz:compileKotlinJvm --info | grep "Incremental" +``` + +## gradle.properties Optimizations + +Add to `gradle.properties` for faster builds: + +```properties +# Daemon +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g + +# Parallel +org.gradle.parallel=true +org.gradle.workers.max=8 + +# Caching +org.gradle.caching=true +org.gradle.configuration-cache=true + +# Kotlin +kotlin.incremental=true +kotlin.daemon.jvmargs=-Xmx2g +``` + +## Common Workflows + +### Full Desktop Build & Run +```bash +./gradlew :desktopApp:clean :desktopApp:run +``` + +### Quick Desktop Iteration +```bash +# No clean - incremental compilation +./gradlew :desktopApp:run +``` + +### Android Release Build +```bash +./gradlew :amethyst:clean :amethyst:bundleRelease +``` + +### Test All KMP Targets +```bash +./gradlew :quartz:test :quartz:testDebugUnitTest +``` + +### Publish Quartz Locally for Testing +```bash +./gradlew :quartz:publishToMavenLocal +# Then update version in consumer project to test +``` diff --git a/.claude/skills/gradle-expert/references/common-errors.md b/.claude/skills/gradle-expert/references/common-errors.md new file mode 100644 index 000000000..a97f59d64 --- /dev/null +++ b/.claude/skills/gradle-expert/references/common-errors.md @@ -0,0 +1,643 @@ +# Common Build Errors & Solutions + +## Table of Contents +- [Compose Version Conflicts](#compose-version-conflicts) +- [secp256k1 JNI Errors](#secp256k1-jni-errors) +- [Source Set Dependency Issues](#source-set-dependency-issues) +- [Proguard/R8 Issues](#proguardr8-issues) +- [Desktop Packaging Errors](#desktop-packaging-errors) +- [Kotlin Compilation Errors](#kotlin-compilation-errors) +- [Dependency Resolution Failures](#dependency-resolution-failures) +- [JVM/JDK Version Issues](#jvmjdk-version-issues) + +--- + +## Compose Version Conflicts + +### Error 1: Compose Runtime Mismatch + +``` +java.lang.IllegalStateException: Version mismatch: Compose runtime is 1.10.0 but compiler is 1.9.0 +``` + +**Cause:** Compose Compiler plugin version doesn't match Compose Runtime + +**Solution:** +```kotlin +// In gradle/libs.versions.toml +composeMultiplatform = "1.9.3" // Must align with Kotlin version +kotlin = "2.3.0" + +// Check compatibility matrix: +// https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html +``` + +**Verification:** +```bash +./gradlew :commons:dependencies | grep compose +``` + +### Error 2: AndroidX Compose BOM Conflict + +``` +Duplicate class androidx.compose.ui.platform.AndroidCompositionLocalMap found in modules... +``` + +**Cause:** Both Compose Multiplatform and AndroidX Compose BOM providing same classes + +**Solution:** +```kotlin +// In commons/build.gradle.kts (KMP module) +// Use Compose Multiplatform, NOT AndroidX BOM +dependencies { + implementation(compose.ui) // ✅ Compose Multiplatform + implementation(compose.material3) + + // Don't use in KMP modules: + // implementation(libs.androidx.compose.bom) // ❌ Android-only +} + +// In amethyst/build.gradle.kts (Android-only module) +// Can use AndroidX BOM +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + implementation(libs.androidx.ui) +} +``` + +### Error 3: Material3 WindowSizeClass Not Found + +``` +Unresolved reference: WindowSizeClass +``` + +**Cause:** Using Android's WindowSizeClass in shared KMP code + +**Solution:** +```kotlin +// Don't use in commonMain or jvmAndroid: +// import androidx.compose.material3.windowsizeclass.WindowSizeClass // ❌ + +// Use in androidMain only, or create expect/actual: +// commonMain +expect class WindowSizeClassAdapter + +// androidMain +actual typealias WindowSizeClassAdapter = androidx.compose.material3.windowsizeclass.WindowSizeClass + +// jvmMain (desktop) +actual class WindowSizeClassAdapter { /* Custom impl */ } +``` + +--- + +## secp256k1 JNI Errors + +### Error 1: JNI Library Not Found (Desktop) + +``` +java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path +``` + +**Cause:** Desktop using wrong secp256k1 variant (Android JNI instead of JVM JNI) + +**Solution:** +```kotlin +// In quartz/build.gradle.kts +sourceSets { + jvmMain { + dependencies { + // ✅ Correct - JVM variant + implementation(libs.secp256k1.kmp.jni.jvm) + + // ❌ Wrong - Android variant + // implementation(libs.secp256k1.kmp.jni.android) + } + } +} +``` + +**Verification:** +```bash +./gradlew :quartz:dependencies --configuration jvmRuntimeClasspath | grep secp256k1 +# Should show: secp256k1-kmp-jni-jvm, NOT jni-android +``` + +### Error 2: Version Mismatch Between Variants + +``` +java.lang.NoSuchMethodError: fr.acinq.secp256k1.Secp256k1.sign +``` + +**Cause:** Common, Android, and JVM variants have different versions + +**Solution:** +```toml +# In gradle/libs.versions.toml +# All three MUST use same version +secp256k1KmpJniAndroid = "0.22.0" + +[libraries] +secp256k1-kmp-common = { ..., version.ref = "secp256k1KmpJniAndroid" } +secp256k1-kmp-jni-android = { ..., version.ref = "secp256k1KmpJniAndroid" } +secp256k1-kmp-jni-jvm = { ..., version.ref = "secp256k1KmpJniAndroid" } +``` + +### Error 3: Android JNI Not Loaded + +``` +java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader couldn't find "libsecp256k1jni.so" +``` + +**Cause:** Proguard stripping JNI classes + +**Solution:** +```proguard +# In quartz/proguard-rules.pro +-keep class fr.acinq.secp256k1.** { *; } +``` + +--- + +## Source Set Dependency Issues + +### Error 1: jvmAndroid Defined After androidMain + +``` +Could not get unknown property 'jvmAndroid' for source set container +``` + +**Cause:** Source sets must be defined in dependency order + +**Solution:** +```kotlin +// ✅ Correct order +sourceSets { + commonMain { } + + // Define jvmAndroid BEFORE androidMain and jvmMain + val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + } + + androidMain { + dependsOn(jvmAndroid) // Now jvmAndroid exists + } + + jvmMain { + dependsOn(jvmAndroid) + } +} +``` + +### Error 2: Dependency in Wrong Source Set + +``` +Unresolved reference: ObjectMapper (Jackson) +``` + +**Cause:** JVM-only library in commonMain + +**Solution:** +```kotlin +sourceSets { + commonMain { + // ❌ Jackson is JVM-only, can't use here + // implementation(libs.jackson.module.kotlin) + } + + val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + // ✅ Jackson in jvmAndroid (shared JVM code) + api(libs.jackson.module.kotlin) + } +} +``` + +### Error 3: Platform-Specific Code in Shared Source Set + +``` +java.lang.NoClassDefFoundError: android.content.Context +``` + +**Cause:** Android-specific API in jvmAndroid or commonMain + +**Solution:** +```kotlin +// Use expect/actual pattern + +// commonMain +expect class PlatformContext + +// androidMain +actual typealias PlatformContext = android.content.Context + +// jvmMain +actual class PlatformContext { + // Custom desktop implementation +} +``` + +--- + +## Proguard/R8 Issues + +### Error 1: Native Library Classes Stripped + +``` +java.lang.NoClassDefFoundError: com.goterl.lazysodium.Sodium +``` + +**Cause:** R8/Proguard removing JNA/LibSodium classes + +**Solution:** +```proguard +# In quartz/proguard-rules.pro +-keep class com.goterl.lazysodium.** { *; } +-keep class com.sun.jna.** { *; } +-keep class fr.acinq.secp256k1.** { *; } +``` + +### Error 2: Reflection-Based Libraries Broken + +``` +com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of ... +``` + +**Cause:** Jackson uses reflection, R8 strips class metadata + +**Solution:** +```proguard +# Preserve reflection metadata +-keepattributes *Annotation* +-keepattributes Signature +-keepattributes InnerClasses + +# Keep all Quartz event classes +-keep class com.vitorpamplona.quartz.** { *; } +``` + +### Error 3: Enum Values Missing + +``` +java.lang.IllegalArgumentException: No enum constant ... +``` + +**Cause:** R8 obfuscating enum names + +**Solution:** +```proguard +# Keep all enums +-keep enum ** { *; } +-keepnames class ** { *; } +``` + +--- + +## Desktop Packaging Errors + +### Error 1: Icon Not Found + +``` +FAILURE: Build failed with an exception. +* What went wrong: Cannot find icon file: src/jvmMain/resources/icon.icns +``` + +**Cause:** Icon file missing or wrong path + +**Solution:** +```kotlin +// In desktopApp/build.gradle.kts +nativeDistributions { + macOS { + // Ensure file exists at this path + iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + } + + // Check file exists: + // ls -la desktopApp/src/jvmMain/resources/ +} +``` + +**Icon Requirements:** +- macOS: `.icns` (512x512, 256x256, 128x128, 32x32) +- Windows: `.ico` (256x256, 128x128, 64x64, 32x32, 16x16) +- Linux: `.png` (512x512 recommended) + +### Error 2: Main Class Not Found + +``` +Error: Could not find or load main class com.vitorpamplona.amethyst.desktop.MainKt +``` + +**Cause:** Wrong mainClass path or Main.kt doesn't have main() + +**Solution:** +```kotlin +// In desktopApp/build.gradle.kts +compose.desktop { + application { + mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + // ^^^^ + // Kotlin compiler adds "Kt" suffix + } +} + +// In src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +fun main() = application { + // ... +} +``` + +### Error 3: Native Library Missing in Package + +``` +java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path +``` + +**Cause:** Native libraries not bundled in distribution + +**Solution:** +```kotlin +// Native libs are automatically included via dependencies +// Verify secp256k1-kmp-jni-jvm is in dependencies: +dependencies { + implementation(libs.secp256k1.kmp.jni.jvm) // ✅ Includes native libs +} + +// Test packaged app: +./gradlew :desktopApp:createDistributable +# Run from: desktopApp/build/compose/binaries/main/app/ +``` + +--- + +## Kotlin Compilation Errors + +### Error 1: Expect/Actual Mismatch + +``` +'actual' declaration has no corresponding expected declaration +``` + +**Cause:** Signature mismatch or missing expect + +**Solution:** +```kotlin +// commonMain - expect declaration +expect class CryptoProvider { + fun sign(message: ByteArray, privateKey: ByteArray): ByteArray +} + +// androidMain & jvmMain - actual must match EXACTLY +actual class CryptoProvider { + actual fun sign(message: ByteArray, privateKey: ByteArray): ByteArray { + // Implementation + } +} + +// Common mistakes: +// - Different parameter names ❌ +// - Different return types ❌ +// - Missing 'actual' modifier ❌ +``` + +### Error 2: Target JVM Version Mismatch + +``` +Compilation failed: module was compiled with an incompatible version of Kotlin +``` + +**Cause:** Different JVM targets across modules + +**Solution:** +```kotlin +// Ensure ALL modules use same JVM target + +// In quartz/build.gradle.kts +kotlin { + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) // ✅ Java 21 + } + } +} + +// In android {} block +compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} +``` + +### Error 3: Compose Compiler Plugin Missing + +``` +This declaration needs opt-in. Please use @OptIn(ComposeApi::class) or @Composable +``` + +**Cause:** Compose compiler plugin not applied + +**Solution:** +```kotlin +// In build.gradle.kts +plugins { + alias(libs.plugins.jetbrainsComposeCompiler) // ✅ Add this + alias(libs.plugins.composeMultiplatform) +} +``` + +--- + +## Dependency Resolution Failures + +### Error 1: Repository Not Found + +``` +Could not find com.github.vitorpamplona.compose-richtext:richtext-ui:f92ef49c9d +``` + +**Cause:** Jitpack or custom Maven repository not configured + +**Solution:** +```kotlin +// In settings.gradle +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { url = "https://jitpack.io" } // ✅ Add Jitpack + } +} +``` + +### Error 2: Gradle Version Too Old + +``` +Version catalogs are not supported in this version of Gradle +``` + +**Cause:** Gradle < 7.0 + +**Solution:** +```properties +# In gradle/wrapper/gradle-wrapper.properties +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +``` + +Then: `./gradlew wrapper --gradle-version=8.9` + +### Error 3: Dependency Variant Not Found + +``` +No matching variant of fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0 was found +``` + +**Cause:** Wrong dependency configuration for target + +**Solution:** +```kotlin +// In androidMain (Android library module) +dependencies { + // For AAR packaging + implementation("net.java.dev.jna:jna:5.18.1@aar") // ✅ Specify @aar + + // secp256k1 works without @aar (auto-detects) + api(libs.secp256k1.kmp.jni.android) +} +``` + +--- + +## JVM/JDK Version Issues + +### Error 1: Unsupported Class File Version + +``` +Unsupported class file major version 65 +``` + +**Cause:** Compiled with Java 21, running with older Java + +**Solution:** +```bash +# Check Java version +java -version # Should show 21 + +# Set JAVA_HOME if needed +export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home + +# Or in gradle.properties +org.gradle.java.home=/path/to/jdk-21 +``` + +### Error 2: JVM Toolchain Not Found + +``` +No matching toolchain found for requested JvmVersion +``` + +**Cause:** Java 21 not installed or not detected + +**Solution:** +```bash +# macOS (Homebrew) +brew install openjdk@21 + +# Ubuntu +sudo apt install openjdk-21-jdk + +# Set JAVA_HOME +export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS +export JAVA_HOME=/usr/lib/jvm/java-21-openjdk # Linux + +# Verify +./gradlew -version +``` + +### Error 3: Gradle Daemon Using Wrong Java + +``` +Daemon will be stopped at the end of the build because JVM version has changed +``` + +**Cause:** Daemon started with different Java version + +**Solution:** +```bash +# Stop all daemons +./gradlew --stop + +# Start with correct JAVA_HOME +export JAVA_HOME=/path/to/jdk-21 +./gradlew build + +# Or set in gradle.properties permanently +org.gradle.java.home=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home +``` + +--- + +## General Troubleshooting Steps + +### Step 1: Clean Build +```bash +./gradlew clean +./gradlew --stop # Stop daemon +./gradlew build +``` + +### Step 2: Check Dependencies +```bash +./gradlew :moduleName:dependencies +./gradlew dependencyInsight --dependency libraryName +``` + +### Step 3: Enable Debug Logging +```bash +./gradlew build --info # Info logging +./gradlew build --debug # Debug logging (verbose) +./gradlew build --stacktrace +``` + +### Step 4: Invalidate Caches +```bash +# Clear Gradle cache +rm -rf ~/.gradle/caches/ + +# Clear build outputs +./gradlew clean + +# Clear Gradle wrapper cache +rm -rf ~/.gradle/wrapper/ +``` + +### Step 5: Build Scan +```bash +./gradlew build --scan +# Opens interactive diagnostics in browser +``` + +## Quick Reference: Error Keywords → Solution + +| Error Keyword | Likely Cause | Quick Fix | +|---------------|--------------|-----------| +| `UnsatisfiedLinkError` | Wrong JNI variant | Check secp256k1/JNA variants by platform | +| `IllegalStateException` (Compose) | Version mismatch | Align Compose Multiplatform + Kotlin versions | +| `NoClassDefFoundError` | Proguard stripping | Add `-keep` rule for class | +| `Unresolved reference` | Wrong source set | Move to appropriate source set (jvmAndroid) | +| `Duplicate class` | BOM conflict | Remove AndroidX BOM from KMP modules | +| `Version mismatch` | Plugin/runtime version mismatch | Update libs.versions.toml | +| `No matching variant` | Repository or packaging issue | Add repository or @aar suffix | +| `Could not find` (dependency) | Missing repository | Add maven/jitpack to repositories | +| `Unsupported class file` | Java version mismatch | Update JAVA_HOME to Java 21 | + +--- + +## Getting Help + +1. **Check Build Scan**: `./gradlew build --scan` for detailed diagnostics +2. **Gradle Forums**: https://discuss.gradle.org/ +3. **Kotlin Slack**: #multiplatform channel +4. **Stack Overflow**: Tags `gradle`, `kotlin-multiplatform`, `compose-multiplatform` diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md new file mode 100644 index 000000000..3f3820398 --- /dev/null +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -0,0 +1,266 @@ +# Module Dependency Graph + +## Visual Hierarchy + +``` +┌─────────────────────────────────────────────────────────┐ +│ Root Project │ +│ (Amethyst) │ +└─────────────────────────────────────────────────────────┘ + │ + ┌────────────────┼────────────────┬────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ +│ :amethyst │ │ :desktopApp │ │ :benchmark │ │:ammolite │ +│ (Android) │ │ (JVM) │ │ (Android) │ │ (Support)│ +└─────────────┘ └─────────────┘ └─────────────┘ └──────────┘ + │ │ │ + │ │ │ + └────────────────┼────────────────┘ + │ + ▼ + ┌─────────────┐ + │ :commons │ + │ (KMP UI) │ + │ │ + │ jvmAndroid │ + │ / \ │ + │ jvm android│ + └─────────────┘ + │ + │ + ▼ + ┌─────────────┐ + │ :quartz │ + │(KMP Library)│ + │ │ + │ commonMain │ + │ │ │ + │ jvmAndroid │ + │ / | \ │ + │jvm and ios │ + └─────────────┘ +``` + +## Module Details + +### :quartz (KMP Nostr Library) +**Type:** Kotlin Multiplatform Library +**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64) +**Dependencies:** +- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable +- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain + +**Role:** Core Nostr protocol implementation, shared across all platforms + +### :commons (Shared UI Components) +**Type:** Kotlin Multiplatform Library +**Targets:** JVM, Android +**Dependencies:** +- Module: `:quartz` +- External: Compose Multiplatform, Material3, kotlinx.collections.immutable +- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain} + +**Role:** Shared Compose UI components for Desktop and Android + +### :desktopApp (Desktop Application) +**Type:** JVM Application +**Targets:** JVM (Desktop) +**Dependencies:** +- Modules: `:commons`, `:quartz` +- External: Compose Desktop, kotlinx.coroutines.swing + +**Role:** Desktop-specific navigation, layouts, and entry point + +### :amethyst (Android Application) +**Type:** Android Application +**Targets:** Android +**Dependencies:** +- Modules: `:commons`, `:quartz`, `:ammolite` +- External: Android SDK, AndroidX, Firebase, Tor + +**Role:** Android-specific navigation, layouts, and entry point + +### :benchmark (Android Benchmark) +**Type:** Android Library +**Targets:** Android +**Dependencies:** +- Modules: `:commons`, `:quartz` +- External: AndroidX Benchmark + +**Role:** Performance benchmarking for Android builds + +### :ammolite (Support Module) +**Type:** Android Library +**Targets:** Android +**Dependencies:** Android-specific utilities + +**Role:** Android support utilities for amethyst + +## Dependency Flow Patterns + +### Desktop Build Chain +``` +:desktopApp → :commons (jvmMain) → :quartz (jvmMain) + ↓ + jvmAndroid + ↓ + commonMain +``` + +### Android Build Chain +``` +:amethyst → :commons (androidMain) → :quartz (androidMain) + ↓ ↓ +:ammolite jvmAndroid + ↓ + commonMain +``` + +## Source Set Dependencies + +### :quartz Source Sets +``` +commonMain (base) + ├─ jvmAndroid (shared JVM code) + │ ├─ androidMain (Android platform) + │ └─ jvmMain (Desktop platform) + └─ iosMain (iOS platform) + ├─ iosX64Main + ├─ iosArm64Main + └─ iosSimulatorArm64Main +``` + +**Key Dependencies per Source Set:** +- **commonMain**: secp256k1-kmp, kotlinx.coroutines, collection, immutable collections +- **jvmAndroid**: jackson, okhttp, url-detector, rfc3986 +- **androidMain**: secp256k1-kmp-jni-android, lazysodium-android, jna (aar) +- **jvmMain**: secp256k1-kmp-jni-jvm, lazysodium-java, jna (jar) + +### :commons Source Sets +``` +commonMain (base UI) + └─ jvmAndroid (shared JVM UI) + ├─ androidMain (Android UI utilities) + └─ jvmMain (Desktop UI utilities) +``` + +**Key Dependencies per Source Set:** +- **commonMain**: Compose Multiplatform, Material3, :quartz +- **jvmAndroid**: url-detector +- **androidMain**: AndroidX Compose tooling +- **jvmMain**: Compose Desktop + +## Critical Dependency Patterns + +### 1. secp256k1 Variants +```kotlin +// commonMain - API only +api(libs.secp256k1.kmp.common) + +// androidMain - JNI Android +api(libs.secp256k1.kmp.jni.android) + +// jvmMain - JNI JVM +implementation(libs.secp256k1.kmp.jni.jvm) +``` +**Why:** Different JNI bindings for Android vs Desktop JVM + +### 2. JNA Variants (for LibSodium) +```kotlin +// androidMain +implementation("com.goterl:lazysodium-android:5.2.0@aar") +implementation("net.java.dev.jna:jna:5.18.1@aar") + +// jvmMain +implementation(libs.lazysodium.java) +implementation(libs.jna) // JAR variant +``` +**Why:** Android needs AAR packaging, JVM needs JAR + +### 3. Compose Alignment +```kotlin +// commons/build.gradle.kts +implementation(compose.ui) // Compose Multiplatform BOM +implementation(compose.material3) + +// Version catalog alignment +composeMultiplatform = "1.9.3" +composeBom = "2025.12.01" // AndroidX Compose +``` +**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align + +## Dependency Configuration Types + +### API vs Implementation + +**Use `api` when:** +- Dependency types appear in module's public API +- Used in expect/actual declarations visible to consumers +- Example: `secp256k1-kmp-common` in quartz (public types) + +**Use `implementation` when:** +- Internal implementation detail +- Not exposed to module consumers +- Example: `okhttp` in quartz (internal network client) + +### Example from quartz +```kotlin +// Public API - exposed to consumers +api(libs.secp256k1.kmp.common) +api(libs.jackson.module.kotlin) // Event serialization public + +// Internal implementation +implementation(libs.okhttp) +implementation(libs.kotlinx.coroutines.core) +``` + +## Transitive Dependency Impact + +### When :desktopApp depends on :commons +- Gets `:quartz` transitively (via :commons) +- Gets `secp256k1-kmp-jvm` transitively (via :quartz jvmMain) +- Does NOT get Android-specific dependencies (scoped to androidMain) + +### When :amethyst depends on :commons +- Gets `:quartz` transitively (via :commons) +- Gets `secp256k1-kmp-jni-android` transitively (via :quartz androidMain) +- Does NOT get JVM/Desktop-specific dependencies (scoped to jvmMain) + +## Verifying Dependencies + +### Check Module Dependencies +```bash +./gradlew :desktopApp:dependencies +./gradlew :amethyst:dependencies +``` + +### Check Specific Library +```bash +./gradlew dependencyInsight --dependency secp256k1 +./gradlew dependencyInsight --dependency compose-ui +``` + +### Visualize with Build Scan +```bash +./gradlew :desktopApp:dependencies --scan +# Opens interactive dependency graph in browser +``` + +## Common Dependency Issues + +### Issue 1: Wrong secp256k1 Variant in Desktop +**Symptom:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path` +**Cause:** Desktop using Android JNI variant +**Fix:** Ensure jvmMain uses `secp256k1-kmp-jni-jvm` + +### Issue 2: Compose Version Mismatch +**Symptom:** `IllegalStateException: Version mismatch` +**Cause:** Compose Multiplatform plugin vs runtime version mismatch +**Fix:** Align `composeMultiplatform` version in libs.versions.toml with Kotlin plugin + +### Issue 3: Duplicate JNA Classes +**Symptom:** `DuplicateClassException: com.sun.jna.Native` +**Cause:** Both JAR and AAR JNA variants in classpath +**Fix:** Use AAR (@aar) in androidMain, JAR in jvmMain (never in shared source sets) diff --git a/.claude/skills/gradle-expert/references/version-catalog-guide.md b/.claude/skills/gradle-expert/references/version-catalog-guide.md new file mode 100644 index 000000000..0c50df357 --- /dev/null +++ b/.claude/skills/gradle-expert/references/version-catalog-guide.md @@ -0,0 +1,422 @@ +# Version Catalog Guide + +## Overview + +AmethystMultiplatform uses Gradle's version catalog (`gradle/libs.versions.toml`) to centralize dependency management. This ensures version consistency across all modules and simplifies updates. + +## Structure + +### sections +```toml +[versions] # Version numbers (referenced by libraries and plugins) +[libraries] # Library dependencies +[plugins] # Gradle plugins +``` + +## Version References + +### Defining Versions +```toml +[versions] +kotlin = "2.3.0" +composeMultiplatform = "1.9.3" +okhttp = "5.3.2" +``` + +### Special Patterns + +#### Android SDK Versions +```toml +android-compileSdk = "36" +android-minSdk = "26" +android-targetSdk = "36" +``` +**Access in build.gradle.kts:** +```kotlin +compileSdk = libs.versions.android.compileSdk.get().toInt() +minSdk = libs.versions.android.minSdk.get().toInt() +``` + +#### Version Suffixes (Git Commits) +```toml +androidKotlinGeohash = "b481c6a64e" # Jitpack commit hash +markdown = "f92ef49c9d" +``` +**Why:** For GitHub dependencies via Jitpack that don't have semantic versions + +## Library Declarations + +### Basic Pattern +```toml +[libraries] +library-name = { group = "...", name = "...", version.ref = "..." } +``` + +### Examples + +#### Version Reference +```toml +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +``` + +#### Module Reference (for multi-artifact libs) +```toml +androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" } +``` + +#### Without Group (shorthand) +```toml +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +``` +**Note:** Inherits version from BOM (compose-bom) + +### BOMs (Bill of Materials) + +#### AndroidX Compose BOM +```toml +[versions] +composeBom = "2025.12.01" + +[libraries] +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +``` + +**Usage in build.gradle.kts:** +```kotlin +val composeBom = platform(libs.androidx.compose.bom) +implementation(composeBom) +implementation(libs.androidx.ui) // Version from BOM +implementation(libs.androidx.material3) // Version from BOM +``` + +**Benefits:** +- All AndroidX Compose artifacts use compatible versions +- Update single BOM version, not individual libraries +- Prevents version conflicts + +### Platform-Specific Variants + +#### secp256k1 (KMP crypto library) +```toml +secp256k1KmpJniAndroid = "0.22.0" + +[libraries] +secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } +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" } +``` + +**Critical:** All three variants MUST share the same version + +#### JNA (for LibSodium) +```toml +jna = "5.18.1" + +[libraries] +jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } +``` + +**Usage in build.gradle.kts:** +```kotlin +// androidMain - AAR packaging +implementation("net.java.dev.jna:jna:5.18.1@aar") + +// jvmMain - JAR packaging +implementation(libs.jna) +``` + +**Why:** Android needs AAR, JVM needs JAR (different artifact types) + +## Plugin Declarations + +### Basic Pattern +```toml +[plugins] +plugin-id = { id = "...", version.ref = "..." } +``` + +### Examples + +#### Kotlin Plugins +```toml +[versions] +kotlin = "2.3.0" + +[plugins] +jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' } +``` + +**Critical:** All Kotlin plugins MUST use the same Kotlin version + +#### Android Gradle Plugin +```toml +[versions] +agp = "8.13.2" + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +``` + +#### Compose Multiplatform +```toml +[versions] +composeMultiplatform = "1.9.3" + +[plugins] +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +``` + +### Plugin Application + +```kotlin +// In build.gradle.kts +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.jetbrainsComposeCompiler) +} +``` + +## Usage in Build Files + +### Accessing Versions +```kotlin +// Direct version access +val kotlinVersion = libs.versions.kotlin.get() +val minSdk = libs.versions.android.minSdk.get().toInt() +``` + +### Accessing Libraries +```kotlin +dependencies { + implementation(libs.kotlinx.coroutines.core) + api(libs.secp256k1.kmp.common) + implementation(libs.okhttp) +} +``` + +### Accessing Plugins +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) +} +``` + +## Version Catalog Benefits + +### 1. Centralized Version Management +Update once, applies everywhere: +```toml +# Change one line +kotlin = "2.3.0" → "2.4.0" + +# Affects all usages +- kotlinMultiplatform plugin +- jetbrainsKotlinAndroid plugin +- kotlin-stdlib +- All Kotlin-related dependencies +``` + +### 2. Type-Safe Accessors +```kotlin +// Compile-time checked +implementation(libs.okhttp) // ✅ IDE autocomplete + +// vs string-based (error-prone) +implementation("com.squareup.okhttp3:okhttp:5.3.2") // ❌ No autocomplete +``` + +### 3. Dependency Consistency +```kotlin +// All modules reference same catalog +:quartz → libs.okhttp +:commons → libs.okhttp +:desktopApp → libs.okhttp + +// Same version everywhere +``` + +### 4. Gradle Sync Improvements +- Faster IDE sync (pre-parsed catalog) +- Better dependency resolution +- Clearer error messages + +## Common Patterns + +### GitHub Dependencies (Jitpack) +```toml +[versions] +markdown = "f92ef49c9d" # Git commit hash + +[libraries] +markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" } +``` + +**Repository config** (in settings.gradle): +```kotlin +repositories { + maven { url = "https://jitpack.io" } +} +``` + +### Multi-Artifact Libraries +```toml +[versions] +media3 = "1.9.0" + +[libraries] +androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } +``` + +**Why:** All media3 artifacts share same version for compatibility + +### Test Dependencies +```toml +[libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +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"} +``` + +## Version Update Strategy + +### Check for Updates +```bash +# Using Gradle Versions Plugin (if installed) +./gradlew dependencyUpdates + +# Manual check +# Browse to Maven Central for specific library +``` + +### Update Process +1. **Update version in catalog** + ```toml + okhttp = "5.3.2" → "5.4.0" + ``` + +2. **Test locally** + ```bash + ./gradlew clean build + ``` + +3. **Check for breaking changes** + - Review library changelog + - Run full test suite + +4. **Commit with clear message** + ``` + chore: update okhttp 5.3.2 → 5.4.0 + ``` + +### Critical Version Alignments + +#### Kotlin Ecosystem +```toml +kotlin = "2.3.0" +kotlinxCoroutinesCore = "1.10.2" +kotlinxSerialization = "1.9.0" +``` +**Rule:** Kotlin version must be compatible with kotlinx libraries + +#### Compose Ecosystem +```toml +composeMultiplatform = "1.9.3" +composeBom = "2025.12.01" +kotlin = "2.3.0" +``` +**Rule:** Compose Multiplatform → Kotlin version (see compatibility matrix) + +#### AGP & Gradle +```toml +agp = "8.13.2" +# Requires Gradle 8.9+ +``` +**Rule:** AGP version dictates minimum Gradle version + +## Troubleshooting + +### Issue 1: Unresolved Reference +**Error:** `Unresolved reference: libs` + +**Cause:** Gradle version < 7.0 (version catalogs not supported) + +**Fix:** Upgrade Gradle in `gradle/wrapper/gradle-wrapper.properties` + +### Issue 2: Library Not Found +**Error:** `Could not find com.example:library:1.0.0` + +**Cause:** Repository not configured or typo in catalog + +**Fix:** +1. Check repository in settings.gradle +2. Verify group/name/version in libs.versions.toml + +### Issue 3: Version Conflict +**Error:** `Conflict with dependency ... and ...` + +**Cause:** Different versions of same library via transitive dependencies + +**Fix:** +```kotlin +configurations.all { + resolutionStrategy { + force(libs.okhttp.get().toString()) + } +} +``` + +## Best Practices + +### 1. Naming Conventions +```toml +# Hyphen-separated, hierarchical +androidx-compose-ui +androidx-compose-material3 +kotlinx-coroutines-core + +# Platform suffixes +secp256k1-kmp-jni-android +secp256k1-kmp-jni-jvm +``` + +### 2. Group Related Dependencies +```toml +# Camera APIs together +androidx-camera-core +androidx-camera-camera2 +androidx-camera-view +``` + +### 3. Document Special Cases +```toml +# JNA requires @aar for Android (see build.gradle.kts) +jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } +``` + +### 4. Keep BOMs Updated +```toml +# Update BOM, individual libs follow +composeBom = "2025.12.01" # Latest stable +``` + +### 5. Test Version Updates +```bash +# Before committing +./gradlew :quartz:test +./gradlew :commons:test +./gradlew :desktopApp:run +``` diff --git a/.claude/skills/gradle-expert/scripts/analyze-build-time.sh b/.claude/skills/gradle-expert/scripts/analyze-build-time.sh new file mode 100644 index 000000000..49dad60f4 --- /dev/null +++ b/.claude/skills/gradle-expert/scripts/analyze-build-time.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Analyze Gradle build performance and generate report + +set -e + +PROJECT_ROOT="${1:-.}" +cd "$PROJECT_ROOT" + +echo "🔍 Analyzing Gradle build performance..." +echo "========================================" +echo "" + +# Clean build for accurate timing +echo "Running clean build with --profile..." +./gradlew clean build --profile --scan + +# Find the latest profile report +PROFILE_REPORT=$(find build/reports/profile -name "*.html" -type f -printf '%T@ %p\n' | sort -n | tail -1 | cut -f2- -d" ") + +if [ -n "$PROFILE_REPORT" ]; then + echo "" + echo "✅ Profile report generated: $PROFILE_REPORT" + echo "" + echo "📊 Build Performance Summary:" + echo "----------------------------" + + # Extract key metrics if available + if command -v jq &> /dev/null && [ -f "build/reports/profile/profile.json" ]; then + jq -r '.buildTime, .taskExecutionTime' build/reports/profile/profile.json + else + echo "Open the HTML report for detailed analysis:" + echo "file://$PWD/$PROFILE_REPORT" + fi +else + echo "⚠️ Profile report not found" +fi + +echo "" +echo "💡 Build optimization tips:" +echo "- Enable Gradle daemon: org.gradle.daemon=true" +echo "- Parallel execution: org.gradle.parallel=true" +echo "- Configuration cache: org.gradle.configuration-cache=true" +echo "- Build cache: org.gradle.caching=true" +echo "" +echo "Add these to gradle.properties for faster builds" diff --git a/.claude/skills/gradle-expert/scripts/fix-dependency-conflicts.sh b/.claude/skills/gradle-expert/scripts/fix-dependency-conflicts.sh new file mode 100644 index 000000000..17951ace4 --- /dev/null +++ b/.claude/skills/gradle-expert/scripts/fix-dependency-conflicts.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Diagnose and suggest fixes for common dependency conflicts + +set -e + +PROJECT_ROOT="${1:-.}" +cd "$PROJECT_ROOT" + +echo "🔍 Analyzing dependency conflicts..." +echo "====================================" +echo "" + +# Run dependency report +echo "Generating dependency insight report..." +./gradlew dependencies --configuration runtimeClasspath > /tmp/gradle-dependencies.txt 2>&1 || true + +# Check for common conflict patterns +echo "" +echo "🔎 Checking for common issues:" +echo "------------------------------" + +# Check 1: Compose version conflicts +if grep -q "compose" /tmp/gradle-dependencies.txt; then + echo "✓ Compose dependencies found" + echo " Tip: Ensure Compose Multiplatform and AndroidX Compose versions align" + echo " Current project uses:" + echo " - Compose Multiplatform BOM" + echo " - AndroidX Compose BOM" +fi + +# Check 2: secp256k1 variants +if grep -q "secp256k1" /tmp/gradle-dependencies.txt; then + echo "✓ secp256k1 dependencies found" + echo " Ensure correct variant:" + echo " - Android: secp256k1-kmp-jni-android" + echo " - JVM/Desktop: secp256k1-kmp-jni-jvm" + echo " - Common: secp256k1-kmp (transitive)" +fi + +# Check 3: Kotlin version alignment +KOTLIN_VERSION=$(grep "kotlin =" gradle/libs.versions.toml | cut -d'"' -f2) +echo "✓ Kotlin version: $KOTLIN_VERSION" +echo " All Kotlin plugins should use the same version" + +# Check 4: Multiple versions of same library +echo "" +echo "🔍 Checking for version conflicts..." +./gradlew dependencyInsight --configuration runtimeClasspath --dependency okhttp || true + +echo "" +echo "💡 Common fixes:" +echo "---------------" +echo "1. Compose conflicts:" +echo " - Align compose-multiplatform plugin version with runtime" +echo " - Use BOM for AndroidX Compose to enforce consistency" +echo "" +echo "2. secp256k1 conflicts:" +echo " - Use 'api' instead of 'implementation' in source sets" +echo " - Ensure androidMain uses jni-android, jvmMain uses jni-jvm" +echo "" +echo "3. Kotlin version conflicts:" +echo " - Update all kotlin plugins to same version in libs.versions.toml" +echo " - Check for transitive Kotlin dependencies" +echo "" +echo "Run './gradlew dependencyInsight --dependency ' for specific conflicts" diff --git a/.claude/skills/kotlin-coroutines/SKILL.md b/.claude/skills/kotlin-coroutines/SKILL.md new file mode 100644 index 000000000..f49d1dfcc --- /dev/null +++ b/.claude/skills/kotlin-coroutines/SKILL.md @@ -0,0 +1,419 @@ +--- +name: kotlin-coroutines +description: Advanced Kotlin coroutines patterns for AmethystMultiplatform. Use when working with: (1) Structured concurrency (supervisorScope, coroutineScope), (2) Advanced Flow operators (flatMapLatest, combine, merge, shareIn, stateIn), (3) Channels and callbackFlow, (4) Dispatcher management and context switching, (5) Exception handling (CoroutineExceptionHandler, SupervisorJob), (6) Testing async code (runTest, Turbine), (7) Nostr relay connection pools and subscriptions, (8) Backpressure handling in event streams. Delegates to kotlin-expert for basic StateFlow/SharedFlow patterns. Complements nostr-expert for relay communication. +--- + +# Kotlin Coroutines - Advanced Async Patterns + +Expert guidance for complex async operations in Amethyst: relay pools, event streams, structured concurrency, and testing. + +## Mental Model + +``` +Async Architecture in Amethyst: + +Relay Pool (supervisorScope) + ├── Relay 1 (launch) → callbackFlow → Events + ├── Relay 2 (launch) → callbackFlow → Events + └── Relay 3 (launch) → callbackFlow → Events + ↓ + merge() → distinctBy(id) → shareIn + ↓ + Multiple Collectors (ViewModels, Services) +``` + +**Key principles:** +- **supervisorScope** - Children fail independently +- **callbackFlow** - Bridge callbacks to Flow +- **shareIn/stateIn** - Hot flows from cold +- **Backpressure** - buffer(), conflate(), DROP_OLDEST + +## When to Use This Skill + +Use for **advanced** async patterns: +- Multi-relay subscriptions with supervisorScope +- Complex Flow operators (flatMapLatest, combine, merge) +- callbackFlow for Android callbacks (connectivity, location) +- Backpressure handling in high-frequency streams +- Exception handling with CoroutineExceptionHandler +- Testing coroutines with runTest and Turbine + +**Delegate to kotlin-expert for:** +- Basic StateFlow/SharedFlow patterns +- Simple viewModelScope.launch +- MutableStateFlow → asStateFlow() + +## Core Patterns + +### Pattern: callbackFlow for Relay Subscriptions + +```kotlin +// Real pattern from NostrClientStaticReqAsStateFlow.kt +fun INostrClient.reqAsFlow( + relay: NormalizedRelayUrl, + filters: List, +): Flow> = callbackFlow { + val subId = RandomInstance.randomChars(10) + var hasBeenLive = false + val eventIds = mutableSetOf() + var currentEvents = listOf() + + val listener = object : IRequestListener { + override fun onEvent(event: Event, ...) { + if (event.id !in eventIds) { + currentEvents = if (hasBeenLive) { + // After EOSE: prepend + listOf(event) + currentEvents + } else { + // Before EOSE: append + currentEvents + event + } + eventIds.add(event.id) + trySend(currentEvents) + } + } + + override fun onEose(...) { + hasBeenLive = true + } + } + + openReqSubscription(subId, mapOf(relay to filters), listener) + + awaitClose { close(subId) } +} +``` + +**Key techniques:** +1. Deduplication with Set +2. EOSE handling (append → prepend strategy) +3. trySend (non-blocking from callback) +4. awaitClose for cleanup + +### Pattern: Structured Concurrency for Relays + +```kotlin +suspend fun connectToRelays(relays: List) = supervisorScope { + relays.forEach { relay -> + launch { + try { + relay.connect() + relay.subscribe(filters).collect { event -> + eventChannel.send(event) + } + } catch (e: IOException) { + Log.e("Relay", "Connection failed: ${relay.url}", e) + // Other relays continue + } + } + } +} +``` + +**Why supervisorScope:** +- One relay failure doesn't cancel others +- All cancelled together when scope cancelled +- Proper cleanup guaranteed + +### Pattern: Exception Handling for Services + +```kotlin +// Real pattern from PushNotificationReceiverService.kt +class MyService : Service() { + val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Log.e("Service", "Caught: ${throwable.message}", throwable) + } + + private val scope = CoroutineScope( + Dispatchers.IO + SupervisorJob() + exceptionHandler + ) + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} +``` + +**Pattern benefits:** +- SupervisorJob: children fail independently +- ExceptionHandler: log instead of crash +- Scoped lifecycle: cancel all on destroy + +### Pattern: Network Connectivity as Flow + +```kotlin +// Real pattern from ConnectivityFlow.kt +val status = callbackFlow { + val networkCallback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(ConnectivityStatus.Active(...)) + } + override fun onLost(network: Network) { + trySend(ConnectivityStatus.Off) + } + } + + connectivityManager.registerCallback(networkCallback) + + // Initial state + activeNetwork?.let { trySend(ConnectivityStatus.Active(...)) } + + awaitClose { + connectivityManager.unregisterCallback(networkCallback) + } +} + .distinctUntilChanged() + .debounce(200) // Stabilize flapping + .flowOn(Dispatchers.IO) +``` + +**Key patterns:** +1. Emit initial state immediately +2. Register callback in flow body +3. Cleanup in awaitClose +4. Stabilize with debounce + distinctUntilChanged + +### Pattern: Merge Events from Multiple Relays + +```kotlin +fun observeFromRelays( + relays: List, + filters: List +): Flow = + relays.map { relay -> + client.reqAsFlow(relay, filters) + .flatMapConcat { it.asFlow() } + }.merge() + .distinctBy { it.id } +``` + +**Flow:** +- Each relay: `Flow>` +- flatMapConcat: flatten to `Flow` +- merge(): combine all relays +- distinctBy: deduplicate across relays + +## Advanced Operators + +For comprehensive coverage of Flow operators: +- **flatMapLatest, combine, zip, merge** → See [advanced-flow-operators.md](references/advanced-flow-operators.md) +- **shareIn, stateIn** → Conversion to hot flows +- **buffer, conflate** → Backpressure strategies +- **debounce, sample** → Rate limiting + +### Quick Reference + +| Operator | Use Case | Example | +|----------|----------|---------| +| **flatMapLatest** | Cancel previous, switch to new | Search (cancel old query) | +| **combine** | Latest from ALL flows | combine(account, settings, connectivity) | +| **merge** | Single stream from multiple | merge(relay1, relay2, relay3) | +| **shareIn** | Multiple collectors, single upstream | Share expensive computation | +| **stateIn** | StateFlow from Flow | ViewModel state | +| **buffer(DROP_OLDEST)** | High-frequency streams | Real-time event feed | +| **conflate** | Latest only | UI updates | +| **debounce** | Wait for quiet period | Search input | + +## Nostr Relay Patterns + +For complete relay-specific patterns: +→ See [relay-patterns.md](references/relay-patterns.md) + +Covers: +- Multi-relay subscription management +- Connection lifecycle and reconnection +- Event deduplication strategies +- Backpressure for high-frequency events +- EOSE handling patterns + +## Testing + +For comprehensive testing patterns: +→ See [testing-coroutines.md](references/testing-coroutines.md) + +**Quick testing pattern:** + +```kotlin +@Test +fun `relay subscription receives events`() = runTest { + val client = FakeNostrClient() + + client.reqAsFlow(relay, filters).test { + assertEquals(emptyList(), awaitItem()) + + client.sendEvent(event1) + assertEquals(listOf(event1), awaitItem()) + + cancelAndIgnoreRemainingEvents() + } +} +``` + +**Testing tools:** +- `runTest` - Virtual time, auto cleanup +- Turbine `.test {}` - Flow assertions +- `advanceTimeBy()` - Control time +- Fake implementations over mocks + +## Common Scenarios + +### Scenario: Implement New Relay Feature + +**Steps:** +1. callbackFlow for subscription +2. Deduplication (Set of event IDs) +3. awaitClose for cleanup +4. Test with FakeNostrClient + +**Example:** Add subscription for specific event kind + +```kotlin +fun observeKind(kind: Int): Flow = callbackFlow { + val listener = object : IRequestListener { + override fun onEvent(event: Event, ...) { + if (event.kind == kind) { + trySend(event) + } + } + } + client.subscribe(listener) + awaitClose { client.unsubscribe(listener) } +} +``` + +### Scenario: Handle Network Connectivity Changes + +**Steps:** +1. callbackFlow for connectivity +2. flatMapLatest to reconnect +3. debounce to stabilize +4. Exception handling for failures + +**Example:** Reconnect relays on connectivity + +```kotlin +connectivityFlow + .flatMapLatest { status -> + when (status) { + Active -> relayPool.observeEvents() + else -> emptyFlow() + } + } + .catch { e -> Log.e("Error", e) } + .collect { event -> handleEvent(event) } +``` + +### Scenario: Optimize Multi-Collector Performance + +**Steps:** +1. Use shareIn for expensive upstream +2. Configure SharingStarted strategy +3. Set replay buffer size +4. Test with multiple collectors + +**Example:** Share relay subscription + +```kotlin +val events: SharedFlow = client + .reqAsFlow(relay, filters) + .flatMapConcat { it.asFlow() } + .shareIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + replay = 0 + ) +``` + +## Anti-Patterns + +❌ **Using GlobalScope** +```kotlin +GlobalScope.launch { /* Leaks, no structured concurrency */ } +``` + +✅ **Use scoped coroutines** +```kotlin +viewModelScope.launch { /* Cancelled with ViewModel */ } +``` + +--- + +❌ **Forgetting awaitClose** +```kotlin +callbackFlow { + registerCallback() + // Missing cleanup! +} +``` + +✅ **Always cleanup** +```kotlin +callbackFlow { + registerCallback() + awaitClose { unregisterCallback() } +} +``` + +--- + +❌ **Blocking in Flow** +```kotlin +flow.map { Thread.sleep(1000); process(it) } +``` + +✅ **Suspend, don't block** +```kotlin +flow.map { delay(1000); process(it) }.flowOn(Dispatchers.Default) +``` + +--- + +❌ **Ignoring backpressure** +```kotlin +fastProducer.collect { slowConsumer(it) } // Blocks producer! +``` + +✅ **Handle backpressure** +```kotlin +fastProducer + .buffer(64, BufferOverflow.DROP_OLDEST) + .collect { slowConsumer(it) } +``` + +## Delegation + +**Use kotlin-expert for:** +- Basic StateFlow/SharedFlow patterns +- viewModelScope.launch usage +- Simple MutableStateFlow → asStateFlow() + +**Use nostr-expert for:** +- Nostr protocol details (NIPs, event structure) +- Event creation and signing +- Cryptographic operations + +**This skill provides:** +- Advanced async patterns +- Structured concurrency +- Complex Flow operators +- Testing strategies +- Relay-specific async patterns + +## Resources + +- **references/advanced-flow-operators.md** - All Flow operators with examples +- **references/relay-patterns.md** - Nostr relay async patterns from codebase +- **references/testing-coroutines.md** - Complete testing guide + +## Quick Decision Tree + +``` +Need async operation? + ├─ Simple ViewModel state update → kotlin-expert (StateFlow) + ├─ Android callback → This skill (callbackFlow) + ├─ Multiple concurrent operations → This skill (supervisorScope) + ├─ Complex Flow transformation → This skill (references/advanced-flow-operators.md) + ├─ Relay subscription → This skill (references/relay-patterns.md) + └─ Testing async code → This skill (references/testing-coroutines.md) +``` diff --git a/.claude/skills/kotlin-coroutines/references/advanced-flow-operators.md b/.claude/skills/kotlin-coroutines/references/advanced-flow-operators.md new file mode 100644 index 000000000..d7cee8c45 --- /dev/null +++ b/.claude/skills/kotlin-coroutines/references/advanced-flow-operators.md @@ -0,0 +1,309 @@ +# Advanced Flow Operators + +Comprehensive guide to Flow operators for complex async patterns in Amethyst. + +## Transformation Operators + +### flatMapLatest - Cancel Previous, Switch to New + +**Use when:** Latest value matters, previous operations should cancel + +```kotlin +// User types in search box → cancel previous search +searchQuery + .flatMapLatest { query -> + repository.search(query) // Cancels previous search + } + .collect { results -> updateUI(results) } +``` + +**Amethyst pattern:** +```kotlin +// Switch relays based on latest account +accountFlow + .flatMapLatest { account -> + relayPool.observeEvents(account.relays) + } +``` + +### flatMapConcat - Sequential Processing + +**Use when:** Order matters, process one at a time + +```kotlin +eventIds + .flatMapConcat { id -> + repository.fetchEvent(id) + } + .collect { event -> process(event) } +``` + +### flatMapMerge - Concurrent Processing + +**Use when:** Process multiple simultaneously, order doesn't matter + +```kotlin +relays + .flatMapMerge(concurrency = 10) { relay -> + relay.subscribe(filters) + } + .collect { event -> handleEvent(event) } +``` + +## Combination Operators + +### combine - Latest from Multiple Flows + +**Use when:** Need latest value from ALL flows + +```kotlin +combine( + accountFlow, + settingsFlow, + connectivityFlow +) { account, settings, connectivity -> + AppState(account, settings, connectivity) +}.collect { state -> render(state) } +``` + +**Pattern:** Re-emits whenever ANY source emits + +### zip - Pair Values in Order + +**Use when:** Need corresponding values from flows + +```kotlin +zip(requestFlow, responseFlow) { req, res -> + Pair(req, res) +} +``` + +**Pattern:** Waits for BOTH to emit before pairing + +### merge - Combine Multiple Flows + +**Use when:** Treat multiple flows as single stream + +```kotlin +merge( + relay1.events, + relay2.events, + relay3.events +).collect { event -> handleEvent(event) } +``` + +## Backpressure & Buffering + +### shareIn - Hot Flow from Cold + +**Use when:** Multiple collectors should share single upstream + +```kotlin +val sharedEvents = repository.observeEvents() + .shareIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + replay = 0 + ) + +// Multiple collectors share same upstream +sharedEvents.collect { /* collector 1 */ } +sharedEvents.collect { /* collector 2 */ } +``` + +**SharingStarted strategies:** +- `Eagerly` - Start immediately, never stop +- `Lazily` - Start on first subscriber, never stop +- `WhileSubscribed(stopTimeout)` - Stop after last unsubscribe + timeout + +### stateIn - StateFlow from Cold Flow + +**Use when:** Convert Flow to StateFlow (always has value) + +```kotlin +val uiState: StateFlow = repository.observeData() + .map { data -> UiState.Success(data) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UiState.Loading + ) +``` + +**Amethyst pattern:** +```kotlin +// Connectivity status as StateFlow +val connectivity: StateFlow = + connectivityFlow.status + .stateIn( + scope = serviceScope, + started = SharingStarted.Eagerly, + initialValue = ConnectivityStatus.Off + ) +``` + +### buffer - Control Backpressure + +**Use when:** Producer faster than consumer + +```kotlin +eventFlow + .buffer(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) + .collect { event -> slowProcessor(event) } +``` + +**Strategies:** +- `SUSPEND` - Slow down producer (default) +- `DROP_OLDEST` - Drop oldest in buffer +- `DROP_LATEST` - Drop newest emission + +### conflate - Keep Only Latest + +**Use when:** Only latest value matters, skip intermediate + +```kotlin +locationFlow + .conflate() // Skip intermediate locations + .collect { location -> updateMap(location) } +``` + +## Debouncing & Throttling + +### debounce - Wait for Quiet Period + +**Use when:** Wait for user to stop typing + +```kotlin +searchQuery + .debounce(300) // Wait 300ms after last emission + .flatMapLatest { query -> search(query) } +``` + +**Amethyst pattern:** +```kotlin +// ConnectivityFlow.kt:87 +connectivityFlow + .distinctUntilChanged() + .debounce(200) // Wait 200ms for network to stabilize + .flowOn(Dispatchers.IO) +``` + +### sample - Periodic Sampling + +**Use when:** Rate-limit high-frequency emissions + +```kotlin +sensorData + .sample(1000) // Sample every 1 second + .collect { data -> process(data) } +``` + +## Error Handling + +### catch - Handle Upstream Errors + +**Use when:** Graceful degradation needed + +```kotlin +repository.fetchData() + .catch { e -> + Log.e("Error", e) + emit(emptyList()) // Fallback value + } + .collect { data -> updateUI(data) } +``` + +**Pattern:** Only catches UPSTREAM errors, not in collect block + +### retry/retryWhen - Automatic Retry + +```kotlin +relayConnection + .retry(3) { cause -> + cause is IOException // Only retry on network errors + } +``` + +## Context Switching + +### flowOn - Change Upstream Dispatcher + +**Use when:** Offload work from current context + +```kotlin +repository.fetchData() + .map { heavyProcessing(it) } + .flowOn(Dispatchers.Default) // Heavy work on Default + .collect { updateUI(it) } // Collect on Main +``` + +**Critical:** Only affects UPSTREAM operators + +**Amethyst pattern:** +```kotlin +// ConnectivityFlow.kt:87 +callbackFlow { /* ... */ } + .distinctUntilChanged() + .debounce(200) + .flowOn(Dispatchers.IO) // All upstream on IO +``` + +## Common Patterns + +### Pattern: Multi-Relay Subscription + +```kotlin +fun observeFromMultipleRelays(relays: List, filters: List): Flow = + relays.map { relay -> + relay.subscribe(filters) + }.merge() + .distinctBy { it.id } +``` + +### Pattern: Load + Cache + Observe + +```kotlin +fun observeWithCache(id: String): Flow = flow { + // Emit cached value immediately + cache[id]?.let { emit(it) } + + // Then observe updates + emitAll(repository.observe(id)) +}.distinctUntilChanged() +``` + +### Pattern: Retry with Exponential Backoff + +```kotlin +fun Flow.retryWithBackoff( + maxRetries: Int = 3, + initialDelay: Long = 1000 +): Flow = retryWhen { cause, attempt -> + if (attempt >= maxRetries || cause !is IOException) { + false + } else { + delay(initialDelay * (1L shl attempt.toInt())) + true + } +} +``` + +## Performance Tips + +1. **Use shareIn for expensive operations** + - Compute once, share with multiple collectors + +2. **Choose right backpressure strategy** + - UI updates: `conflate()` or `DROP_OLDEST` + - Events: `buffer()` with appropriate size + +3. **flowOn placement matters** + - Place after expensive operators to offload them + +4. **Avoid unnecessary emissions** + - Use `distinctUntilChanged()` when appropriate + - Consider `debounce()` for high-frequency sources + +5. **StateFlow vs SharedFlow** + - StateFlow: Always has value, conflates + - SharedFlow: Optional replay, configurable buffering diff --git a/.claude/skills/kotlin-coroutines/references/relay-patterns.md b/.claude/skills/kotlin-coroutines/references/relay-patterns.md new file mode 100644 index 000000000..2b8f46af3 --- /dev/null +++ b/.claude/skills/kotlin-coroutines/references/relay-patterns.md @@ -0,0 +1,480 @@ +# Nostr Relay Async Patterns + +Proven coroutine patterns for Nostr relay connections, subscriptions, and event streaming in Amethyst. + +## Core Pattern: callbackFlow for Relay Subscriptions + +### Pattern: Subscription as Flow + +**Real implementation from NostrClientStaticReqAsStateFlow.kt:** + +```kotlin +fun INostrClient.reqAsFlow( + relay: NormalizedRelayUrl, + filters: List, +): Flow> = + callbackFlow { + val subId = RandomInstance.randomChars(10) + var hasBeenLive = false + val eventIds = mutableSetOf() + var currentEvents = listOf() + + val listener = object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.id !in eventIds) { + if (hasBeenLive) { + // After EOSE: prepend new events + val list = ArrayList(1 + currentEvents.size) + list.add(event) + list.addAll(currentEvents) + currentEvents = list + } else { + // Before EOSE: append events + currentEvents = currentEvents + event + } + eventIds.add(event.id) + trySend(currentEvents) + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + hasBeenLive = true + } + } + + openReqSubscription(subId, mapOf(relay to filters), listener) + + awaitClose { + close(subId) + } + } +``` + +**Key techniques:** +1. **callbackFlow** - Bridge callback API to Flow +2. **Deduplication** - `eventIds` set prevents duplicates +3. **EOSE handling** - Changes insertion strategy (append → prepend) +4. **awaitClose** - Cleanup when flow cancelled +5. **trySend** - Non-blocking emission from callback + +## Multi-Relay Patterns + +### Pattern: Merge Events from Multiple Relays + +```kotlin +fun observeFromRelays( + relays: List, + filters: List +): Flow = + relays.map { relay -> + client.reqAsFlow(relay, filters) + .flatMapConcat { it.asFlow() } + }.merge() + .distinctBy { it.id } +``` + +**Explanation:** +- Each relay produces `Flow>` +- `flatMapConcat` flattens to `Flow` +- `merge()` combines all relay flows +- `distinctBy` deduplicates across relays + +### Pattern: Concurrent Relay Operations with supervisorScope + +```kotlin +suspend fun subscribeToRelays( + relays: List, + filters: List +) = supervisorScope { + relays.forEach { relay -> + launch { + relay.subscribe(filters).collect { event -> + eventChannel.send(event) + } + } + } +} +``` + +**Why supervisorScope:** +- If one relay fails, others continue +- All children cancelled when scope cancelled +- Structured concurrency maintained + +## Backpressure Handling + +### Pattern: Buffer with Drop Strategy + +**For high-frequency event streams:** + +```kotlin +relayFlow + .buffer( + capacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + .collect { event -> processEvent(event) } +``` + +**Strategy selection:** +- `DROP_OLDEST` - For real-time feeds (lose old events OK) +- `DROP_LATEST` - For priority queues (lose new events OK) +- `SUSPEND` - For critical events (slow down producer) + +### Pattern: Conflate for UI Updates + +```kotlin +val uiEvents: Flow = relayEvents + .map { event -> toUiEvent(event) } + .conflate() // Skip intermediate, show latest + .flowOn(Dispatchers.Default) +``` + +## Connection Management + +### Pattern: Network Connectivity as Flow + +**Real implementation from ConnectivityFlow.kt:** + +```kotlin +@OptIn(FlowPreview::class) +val status = callbackFlow { + trySend(ConnectivityStatus.StartingService) + + val connectivityManager = context.getConnectivityManager() + + val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + connectivityManager.getNetworkCapabilities(network)?.let { + trySend(ConnectivityStatus.Active( + network.networkHandle, + it.isMeteredOrMobileData() + )) + } + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + val isMobile = networkCapabilities.isMeteredOrMobileData() + trySend(ConnectivityStatus.Active( + network.networkHandle, + isMobile + )) + } + + override fun onLost(network: Network) { + trySend(ConnectivityStatus.Off) + } + } + + connectivityManager.registerDefaultNetworkCallback(networkCallback) + + // Send initial state + connectivityManager.activeNetwork?.let { network -> + connectivityManager.getNetworkCapabilities(network)?.let { + trySend(ConnectivityStatus.Active( + network.networkHandle, + it.isMeteredOrMobileData() + )) + } + } + + awaitClose { + connectivityManager.unregisterNetworkCallback(networkCallback) + trySend(ConnectivityStatus.Off) + } +} + .distinctUntilChanged() + .debounce(200) // Stabilize rapid changes + .flowOn(Dispatchers.IO) +``` + +**Key patterns:** +1. **Initial state** - Emit current connectivity immediately +2. **Callback registration** - Register listener in flow body +3. **Cleanup** - Unregister in `awaitClose` +4. **Stabilization** - `debounce(200)` prevents flapping +5. **Deduplication** - `distinctUntilChanged()` skips redundant updates + +### Pattern: Reconnect on Connectivity Change + +```kotlin +connectivityFlow + .flatMapLatest { status -> + when (status) { + is ConnectivityStatus.Active -> { + relayPool.connectAll() + relayPool.observeEvents() + } + else -> emptyFlow() + } + } + .collect { event -> handleEvent(event) } +``` + +## Exception Handling in Async Operations + +### Pattern: CoroutineExceptionHandler + SupervisorJob + +**Real implementation from PushNotificationReceiverService.kt:** + +```kotlin +class PushNotificationReceiverService : FirebaseMessagingService() { + // Catch all uncaught exceptions + val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable) + } + + // Children fail independently, handler catches all + private val scope = CoroutineScope( + Dispatchers.IO + SupervisorJob() + exceptionHandler + ) + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + scope.launch(Dispatchers.IO) { + parseMessage(remoteMessage.data)?.let { receiveIfNew(it) } + } + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} +``` + +**Why this pattern:** +- **SupervisorJob** - One failure doesn't cancel others +- **ExceptionHandler** - Log exceptions, don't crash +- **Scoped lifecycle** - Cancel all on destroy + +### Pattern: Retry with Backoff for Relay Connections + +```kotlin +fun connectWithRetry(relay: Relay): Flow = flow { + var attempt = 0 + val maxRetries = 5 + val baseDelay = 1000L + + while (attempt < maxRetries) { + try { + emit(ConnectionStatus.Connecting) + relay.connect() + emit(ConnectionStatus.Connected) + return@flow + } catch (e: Exception) { + attempt++ + emit(ConnectionStatus.Error(e, attempt)) + + if (attempt < maxRetries) { + val delay = baseDelay * (1L shl attempt) // Exponential backoff + delay(delay) + } + } + } + emit(ConnectionStatus.Failed) +} +``` + +## Subscription Lifecycle + +### Pattern: Auto-Cleanup Subscription + +```kotlin +@Composable +fun ObserveRelayEvents( + filters: List, + onEvent: (Event) -> Unit +) { + val scope = rememberCoroutineScope() + + DisposableEffect(filters) { + val job = scope.launch { + relayClient.reqAsFlow(filters).collect { events -> + events.forEach { onEvent(it) } + } + } + + onDispose { + job.cancel() // Cancels flow, triggers awaitClose + } + } +} +``` + +**Lifecycle:** +1. Composable enters → subscribe +2. filters change → cancel + re-subscribe +3. Composable leaves → cancel + cleanup + +### Pattern: Multiple Concurrent Subscriptions + +```kotlin +fun observeMultipleFeeds( + account: Account +): Flow = channelFlow { + supervisorScope { + // Home feed + launch { + client.reqAsFlow(filters = homeFeedFilters) + .collect { events -> events.forEach { send(it) } } + } + + // Notifications + launch { + client.reqAsFlow(filters = notificationFilters) + .collect { events -> events.forEach { send(it) } } + } + + // DMs + launch { + client.reqAsFlow(filters = dmFilters) + .collect { events -> events.forEach { send(it) } } + } + } +} +``` + +**Benefits:** +- All subscriptions run concurrently +- One failure doesn't affect others (supervisorScope) +- Single output channel for all events + +## Performance Optimization + +### Pattern: Shared Upstream for Multiple Collectors + +```kotlin +class RelayViewModel(private val client: INostrClient) : ViewModel() { + val events: SharedFlow = client + .reqAsFlow(relay, filters) + .flatMapConcat { it.asFlow() } + .shareIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + replay = 0 + ) +} + +// Multiple collectors share single relay subscription +events.collect { /* UI 1 */ } +events.collect { /* UI 2 */ } +``` + +### Pattern: Event Deduplication Cache + +```kotlin +class EventCache { + private val seen = mutableSetOf() + + fun filterNew(events: List): List = + events.filter { event -> + if (event.id in seen) { + false + } else { + seen.add(event.id) + true + } + } +} + +val deduplicatedEvents = relayEvents + .map { events -> cache.filterNew(events) } + .filter { it.isNotEmpty() } +``` + +## Testing Relay Flows + +### Pattern: Test with Fake Relay + +```kotlin +@Test +fun `subscription receives events`() = runTest { + val fakeRelay = FakeRelay() + val client = NostrClient(fakeRelay) + + val events = mutableListOf() + val job = launch { + client.reqAsFlow(relay, filters).collect { list -> + events.addAll(list) + } + } + + // Simulate relay responses + fakeRelay.sendEvent(testEvent1) + advanceTimeBy(100) + fakeRelay.sendEvent(testEvent2) + advanceTimeBy(100) + + assertEquals(2, events.size) + job.cancel() +} +``` + +## Common Pitfalls + +### ❌ Forgetting awaitClose + +```kotlin +// BAD: Subscription never cleaned up +callbackFlow { + relay.subscribe(listener) + // Missing awaitClose! +} +``` + +```kotlin +// GOOD: Proper cleanup +callbackFlow { + relay.subscribe(listener) + awaitClose { + relay.unsubscribe(listener) + } +} +``` + +### ❌ Using GlobalScope + +```kotlin +// BAD: Unstructured, leaks +GlobalScope.launch { + relay.connect() +} +``` + +```kotlin +// GOOD: Scoped to lifecycle +viewModelScope.launch { + relay.connect() +} +``` + +### ❌ Blocking in Flow Operators + +```kotlin +// BAD: Blocks collector +flow.map { event -> + Thread.sleep(1000) // Blocks! + process(event) +} +``` + +```kotlin +// GOOD: Use flowOn to offload +flow + .map { event -> + delay(1000) // Suspends, doesn't block + process(event) + } + .flowOn(Dispatchers.Default) +``` diff --git a/.claude/skills/kotlin-coroutines/references/testing-coroutines.md b/.claude/skills/kotlin-coroutines/references/testing-coroutines.md new file mode 100644 index 000000000..a2083c49c --- /dev/null +++ b/.claude/skills/kotlin-coroutines/references/testing-coroutines.md @@ -0,0 +1,493 @@ +# Testing Coroutines + +Comprehensive guide for testing async code with runTest, Turbine, and best practices. + +## runTest - Standard Testing + +### Basic Pattern + +```kotlin +@Test +fun `test suspend function`() = runTest { + val result = repository.fetchData() + assertEquals(expected, result) +} +``` + +**What runTest does:** +- Skips delays automatically +- Provides TestScope +- Advances virtual time +- Waits for all coroutines to complete + +### Testing StateFlow + +```kotlin +@Test +fun `stateflow updates correctly`() = runTest { + val viewModel = MyViewModel() + + // Initial state + assertEquals(UiState.Loading, viewModel.state.value) + + // Trigger action + viewModel.loadData() + advanceUntilIdle() // Run all pending coroutines + + // Verify final state + assertEquals(UiState.Success(data), viewModel.state.value) +} +``` + +### Testing with Time Control + +```kotlin +@Test +fun `debounce works correctly`() = runTest { + val viewModel = SearchViewModel() + + viewModel.search("a") + advanceTimeBy(100) // 100ms passed + + viewModel.search("ab") + advanceTimeBy(100) + + viewModel.search("abc") + advanceTimeBy(300) // Debounce completes + + // Only "abc" should have triggered search + assertEquals(listOf("abc"), viewModel.searchQueries) +} +``` + +**Time control functions:** +- `advanceTimeBy(millis)` - Move virtual time forward +- `advanceUntilIdle()` - Run all pending work +- `runCurrent()` - Run currently scheduled tasks only + +## Turbine - Flow Testing Library + +### Basic Collection Testing + +```kotlin +@Test +fun `flow emits expected values`() = runTest { + repository.observeData().test { + assertEquals(Item1, awaitItem()) + assertEquals(Item2, awaitItem()) + assertEquals(Item3, awaitItem()) + awaitComplete() + } +} +``` + +### Testing Flow Transformations + +```kotlin +@Test +fun `map transforms correctly`() = runTest { + val source = flowOf(1, 2, 3) + + source + .map { it * 2 } + .test { + assertEquals(2, awaitItem()) + assertEquals(4, awaitItem()) + assertEquals(6, awaitItem()) + awaitComplete() + } +} +``` + +### Testing Relay Subscriptions + +```kotlin +@Test +fun `relay subscription receives events`() = runTest { + val fakeClient = FakeNostrClient() + + fakeClient.reqAsFlow(relay, filters).test { + // Initially empty + assertEquals(emptyList(), awaitItem()) + + // Send event + fakeClient.sendEvent(event1) + assertEquals(listOf(event1), awaitItem()) + + // Send another + fakeClient.sendEvent(event2) + assertEquals(listOf(event1, event2), awaitItem()) + + cancelAndIgnoreRemainingEvents() + } +} +``` + +### Testing Error Handling + +```kotlin +@Test +fun `catch handles errors gracefully`() = runTest { + val errorFlow = flow { + emit(1) + throw IOException("Network error") + }.catch { emit(-1) } // Fallback value + + errorFlow.test { + assertEquals(1, awaitItem()) + assertEquals(-1, awaitItem()) + awaitComplete() + } +} +``` + +### Testing StateFlow with Turbine + +```kotlin +@Test +fun `stateflow emits updates`() = runTest { + val viewModel = MyViewModel() + + viewModel.state.test { + // Skip initial value + assertEquals(UiState.Loading, awaitItem()) + + // Trigger update + viewModel.loadData() + assertEquals(UiState.Success(data), awaitItem()) + + cancelAndIgnoreRemainingEvents() + } +} +``` + +**Turbine assertions:** +- `awaitItem()` - Get next emission or fail +- `awaitComplete()` - Verify flow completed +- `awaitError()` - Verify flow threw exception +- `expectNoEvents()` - Assert no emissions in timeframe +- `cancelAndIgnoreRemainingEvents()` - Stop test + +## Testing Patterns for Amethyst + +### Pattern: Test Relay Connection Flow + +```kotlin +@Test +fun `reconnects on connectivity change`() = runTest { + val connectivityFlow = MutableStateFlow(ConnectivityStatus.Off) + val relayPool = FakeRelayPool() + + connectivityFlow + .flatMapLatest { status -> + when (status) { + is ConnectivityStatus.Active -> relayPool.connectAll() + else -> emptyFlow() + } + } + .test { + // Initially offline + expectNoEvents() + + // Go online + connectivityFlow.value = ConnectivityStatus.Active(1L, false) + assertTrue(relayPool.connected) + + cancelAndIgnoreRemainingEvents() + } +} +``` + +### Pattern: Test Event Deduplication + +```kotlin +@Test +fun `deduplicates events across relays`() = runTest { + val relay1 = FakeRelay() + val relay2 = FakeRelay() + + merge(relay1.events, relay2.events) + .distinctBy { it.id } + .test { + // Both relays send same event + relay1.send(event1) + relay2.send(event1) + + // Only one emission + assertEquals(event1, awaitItem()) + expectNoEvents() + + cancelAndIgnoreRemainingEvents() + } +} +``` + +### Pattern: Test Backpressure Handling + +```kotlin +@Test +fun `drops oldest events when buffer full`() = runTest { + val fastProducer = flow { + repeat(100) { emit(it) } + } + + fastProducer + .buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST) + .test { + // Slow consumer + delay(100) + + // Should have dropped oldest, kept newest + val items = mutableListOf() + repeat(10) { + items.add(awaitItem()) + } + + // Newest items present + assertTrue(90 in items) + assertTrue(99 in items) + + awaitComplete() + } +} +``` + +### Pattern: Test Concurrent Subscriptions + +```kotlin +@Test +fun `multiple subscriptions run concurrently`() = runTest { + val client = FakeNostrClient() + + val feed1 = async { client.reqAsFlow(relay1, filters1).first() } + val feed2 = async { client.reqAsFlow(relay2, filters2).first() } + + client.sendTo(relay1, event1) + client.sendTo(relay2, event2) + + assertEquals(listOf(event1), feed1.await()) + assertEquals(listOf(event2), feed2.await()) +} +``` + +## Fakes and Mocks + +### Fake NostrClient + +```kotlin +class FakeNostrClient : INostrClient { + private val subscriptions = mutableMapOf>() + + override fun reqAsFlow( + relay: NormalizedRelayUrl, + filters: List + ): Flow> = callbackFlow { + val subId = RandomInstance.randomChars(10) + val flow = MutableSharedFlow() + subscriptions[subId] = flow + + val events = mutableListOf() + flow.collect { event -> + events.add(event) + send(events.toList()) + } + + awaitClose { + subscriptions.remove(subId) + } + } + + fun sendEvent(event: Event) { + subscriptions.values.forEach { it.tryEmit(event) } + } + + fun sendTo(relay: NormalizedRelayUrl, event: Event) { + subscriptions[relay.url]?.tryEmit(event) + } +} +``` + +### Fake Relay Pool + +```kotlin +class FakeRelayPool { + var connected = false + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + fun connectAll(): Flow = flow { + connected = true + emit(Unit) + } + + fun disconnect() { + connected = false + } + + suspend fun sendEvent(event: Event) { + _events.emit(event) + } +} +``` + +## Testing Exception Handling + +### Test CoroutineExceptionHandler + +```kotlin +@Test +fun `exception handler catches errors`() = runTest { + val errors = mutableListOf() + + val handler = CoroutineExceptionHandler { _, throwable -> + errors.add(throwable) + } + + val scope = CoroutineScope( + Dispatchers.Unconfined + SupervisorJob() + handler + ) + + scope.launch { + throw IOException("Test error") + } + + advanceUntilIdle() + + assertEquals(1, errors.size) + assertTrue(errors[0] is IOException) +} +``` + +### Test Retry Logic + +```kotlin +@Test +fun `retries failed connections`() = runTest { + var attempts = 0 + val maxRetries = 3 + + flow { + attempts++ + if (attempts < maxRetries) { + throw IOException("Connection failed") + } + emit("Success") + } + .retry(maxRetries) + .test { + assertEquals("Success", awaitItem()) + awaitComplete() + assertEquals(3, attempts) + } +} +``` + +## Common Testing Patterns + +### Pattern: Verify No Emissions After Cancellation + +```kotlin +@Test +fun `no emissions after cancellation`() = runTest { + val flow = flow { + emit(1) + delay(1000) + emit(2) // Should not emit + } + + flow.test { + assertEquals(1, awaitItem()) + cancel() + + // Verify no more emissions + expectNoEvents() + } +} +``` + +### Pattern: Test Time-Based Operations + +```kotlin +@Test +fun `periodic emission works`() = runTest { + flow { + repeat(3) { + emit(it) + delay(1000) + } + }.test { + assertEquals(0, awaitItem()) + + advanceTimeBy(1000) + assertEquals(1, awaitItem()) + + advanceTimeBy(1000) + assertEquals(2, awaitItem()) + + awaitComplete() + } +} +``` + +### Pattern: Test Hot Flow Conversion + +```kotlin +@Test +fun `shareIn creates hot flow`() = runTest { + var emissions = 0 + val source = flow { + repeat(3) { + emissions++ + emit(it) + } + } + + val shared = source.shareIn( + scope = this, + started = SharingStarted.Eagerly, + replay = 1 + ) + + // First collector + shared.take(2).collect() + assertEquals(2, emissions) // Emitted 0, 1 + + // Second collector - shares upstream + shared.take(1).collect() + assertEquals(3, emissions) // Only emitted 2, not restarted + + cancel() +} +``` + +## Best Practices + +1. **Use runTest for all coroutine tests** + - Provides virtual time + - Automatic cleanup + +2. **Use Turbine for Flow testing** + - Clearer assertions + - Better error messages + +3. **Test both success and error paths** + - Normal flow + - Exception handling + - Edge cases + +4. **Control virtual time explicitly** + - Don't rely on real delays + - Use `advanceTimeBy()` and `advanceUntilIdle()` + +5. **Create fakes, not mocks** + - Simpler to maintain + - More realistic behavior + - Easier to debug + +6. **Test cancellation behavior** + - Verify cleanup happens + - Check no emissions after cancel + +7. **Test concurrent operations** + - Use `async` to spawn concurrent work + - Verify independence with SupervisorJob diff --git a/.claude/skills/kotlin-expert/SKILL.md b/.claude/skills/kotlin-expert/SKILL.md new file mode 100644 index 000000000..6bd7eb097 --- /dev/null +++ b/.claude/skills/kotlin-expert/SKILL.md @@ -0,0 +1,811 @@ +--- +name: kotlin-expert +description: Advanced Kotlin patterns for AmethystMultiplatform. Flow state management (StateFlow/SharedFlow), sealed hierarchies (classes vs interfaces), immutability (@Immutable, data classes), DSL builders (type-safe fluent APIs), inline functions (reified generics, performance). Use when working with: (1) State management patterns (StateFlow/SharedFlow/MutableStateFlow), (2) Sealed classes or sealed interfaces, (3) @Immutable annotations for Compose, (4) DSL builders with lambda receivers, (5) inline/reified functions, (6) Kotlin performance optimization. Complements kotlin-coroutines agent (async patterns) - this skill focuses on Amethyst-specific Kotlin idioms. +--- + +# Kotlin Expert + +Advanced Kotlin patterns for AmethystMultiplatform. Covers Flow state management, sealed hierarchies, immutability, DSL builders, and inline functions with real codebase examples. + +## Mental Model + +**Kotlin in Amethyst:** + +``` +State Management (Hot Flows) + ├── StateFlow # Single value, always has value, replays to new subscribers + ├── SharedFlow # Event stream, configurable replay, multiple subscribers + └── MutableStateFlow # Private mutable, public via .asStateFlow() + +Type Safety (Sealed Hierarchies) + ├── sealed class # State variants with data (AccountState.LoggedIn/LoggedOut) + └── sealed interface # Generic result types (SignerResult) + +Compose Performance (@Immutable) + ├── @Immutable # 173+ event classes - prevents recomposition + └── data class # Structural equality, copy(), immutable by convention + +DSL Patterns + ├── Builder classes # Fluent APIs (TagArrayBuilder) + ├── Lambda receivers # inline fun tagArray { ... } + └── Method chaining # return this + +Performance + ├── inline fun # Eliminate lambda overhead + ├── reified type params # Runtime type info (OptimizedJsonMapper) + └── value class # Zero-cost wrappers (NOT USED yet in Amethyst) +``` + +**Delegation:** +- **kotlin-coroutines agent**: Deep async (structured concurrency, channels, operators) +- **kotlin-multiplatform skill**: expect/actual, source sets +- **This skill**: Amethyst Kotlin idioms, state patterns, type safety + +--- + +## 1. Flow State Management + +### StateFlow: State that Changes + +**Mental model:** StateFlow is a "hot" observable state holder. Always has a value, new collectors immediately get current state. + +**Amethyst pattern:** + +```kotlin +// AccountManager.kt:48-50 +class AccountManager { + private val _accountState = MutableStateFlow(AccountState.LoggedOut) + val accountState: StateFlow = _accountState.asStateFlow() + + fun login(key: String) { + _accountState.value = AccountState.LoggedIn(...) + } +} +``` + +**Key principles:** +1. **Private mutable, public immutable**: `_accountState` (MutableStateFlow) private, `accountState` (StateFlow) public +2. **Always has value**: Initial value required (`LoggedOut`) +3. **Single value**: Replays ONE most recent value to new subscribers +4. **Hot**: Stays in memory, all collectors share same instance + +**See:** AccountManager.kt:48-50, RelayConnectionManager.kt:49-52 + +### SharedFlow: Event Streams + +**Mental model:** SharedFlow is a "hot" broadcast stream for events. Configurable replay buffer, doesn't require initial value. + +**Amethyst pattern:** + +```kotlin +// RelayConnectionManager.kt:52-53 +val connectedRelays: StateFlow> = client.connectedRelaysFlow() +val availableRelays: StateFlow> = client.availableRelaysFlow() +``` + +**When to use StateFlow vs SharedFlow:** + +| Scenario | Use StateFlow | Use SharedFlow | +|----------|---------------|----------------| +| **UI state** | ✅ Current screen data, login status | ❌ | +| **One-time events** | ❌ | ✅ Navigation, snackbars, toasts | +| **Always has value** | ✅ | ❌ Optional | +| **Replay count** | 1 (latest only) | Configurable (0, 1, n) | +| **Backpressure** | Conflates (drops old) | Configurable buffer | + +**Best practice:** +```kotlin +// State: Use StateFlow +private val _uiState = MutableStateFlow(UiState.Loading) +val uiState: StateFlow = _uiState.asStateFlow() + +// Events: Use SharedFlow +private val _navigationEvents = MutableSharedFlow(replay = 0) +val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() +``` + +### Flow Anti-Patterns + +❌ **Exposing mutable state:** +```kotlin +val accountState: MutableStateFlow // BAD: Can be mutated externally +``` + +✅ **Expose immutable:** +```kotlin +val accountState: StateFlow = _accountState.asStateFlow() // GOOD +``` + +--- + +❌ **SharedFlow for state:** +```kotlin +val loginState = MutableSharedFlow() // BAD: State might get lost +``` + +✅ **StateFlow for state:** +```kotlin +val loginState = MutableStateFlow(LoginState.LoggedOut) // GOOD: Always has value +``` + +**See:** `references/flow-patterns.md` for comprehensive examples. + +--- + +## 2. Sealed Hierarchies + +### Sealed Classes: State Variants + +**Mental model:** Sealed classes represent a closed set of variants that share common data/behavior. + +**Amethyst pattern:** + +```kotlin +// AccountManager.kt:36-46 +sealed class AccountState { + data object LoggedOut : AccountState() + + data class LoggedIn( + val signer: NostrSigner, + val pubKeyHex: String, + val npub: String, + val nsec: String?, + val isReadOnly: Boolean + ) : AccountState() +} + +// Usage +when (state) { + is AccountState.LoggedOut -> showLogin() + is AccountState.LoggedIn -> showFeed(state.pubKeyHex) +} // Exhaustive - compiler enforces all cases +``` + +**Key principles:** +1. **Closed hierarchy**: All subclasses known at compile-time +2. **Exhaustive when**: Compiler ensures all cases handled +3. **Shared data**: Sealed class can hold common properties +4. **Single inheritance**: Subclass can't extend another class + +**When to use:** +- Modeling UI states (Loading, Success, Error) +- Login states (LoggedOut, LoggedIn) +- Result types with different data per variant + +### Sealed Interfaces: Generic Result Types + +**Mental model:** Sealed interfaces for contracts with multiple implementations that need generics or multiple inheritance. + +**Amethyst pattern:** + +```kotlin +// SignerResult.kt:25-46 +sealed interface SignerResult { + sealed interface RequestAddressed : SignerResult { + class Successful(val result: T) : RequestAddressed + class Rejected : RequestAddressed + class TimedOut : RequestAddressed + class ReceivedButCouldNotPerform( + val message: String? + ) : RequestAddressed + } +} + +// Usage with generics +fun handleResult(result: SignerResult) { + when (result) { + is SignerResult.RequestAddressed.Successful -> processEvent(result.result.event) + is SignerResult.RequestAddressed.Rejected -> showRejected() + is SignerResult.RequestAddressed.TimedOut -> showTimeout() + } +} +``` + +**Key principles:** +1. **Multiple inheritance**: Subtype can implement other interfaces +2. **Variance**: Supports `out`/`in` modifiers for generics +3. **No constructor**: Can't hold state directly (subtypes can) +4. **Nested hierarchies**: Can create sub-sealed hierarchies + +### Sealed Class vs Sealed Interface + +| Feature | Sealed Class | Sealed Interface | +|---------|--------------|------------------| +| **Constructor** | ✅ Can hold common state | ❌ No constructor | +| **Inheritance** | ❌ Single parent only | ✅ Multiple interfaces | +| **Generics** | ❌ No variance | ✅ Covariance/contravariance | +| **Use case** | State variants | Result types, contracts | + +**Decision tree:** + +``` +Need to hold common data in base? + YES → sealed class + NO → sealed interface + +Need generics with variance (out/in)? + YES → sealed interface + NO → Either works + +Subtypes need multiple inheritance? + YES → sealed interface + NO → Either works +``` + +**Amethyst examples:** +- `sealed class AccountState` - state variants with different data +- `sealed interface SignerResult` - generic result types with variance + +**See:** `references/sealed-class-catalog.md` for all sealed types in quartz. + +--- + +## 3. Immutability & Compose Performance + +### @Immutable Annotation + +**Mental model:** @Immutable tells Compose "this value never changes after construction." Compose can skip recomposition if @Immutable object reference doesn't change. + +**Amethyst pattern:** + +```kotlin +// TextNoteEvent.kt:51-63 +@Immutable +class TextNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + // All properties immutable (val), no mutable state +} +``` + +**Key principles:** +1. **All properties immutable**: Only `val`, never `var` +2. **No mutable collections**: Use `ImmutableList`, `Array`, not `MutableList` +3. **Deep immutability**: Nested objects also immutable +4. **Compose optimization**: Skips recomposition if reference equals + +**Why it matters:** + +```kotlin +// Without @Immutable +@Composable +fun NoteCard(note: TextNoteEvent) { // Recomposes every time parent recomposes + Text(note.content) +} + +// With @Immutable +@Composable +fun NoteCard(note: TextNoteEvent) { // Only recomposes if note reference changes + Text(note.content) +} +``` + +**173+ @Immutable classes** in quartz - all events immutable for Compose performance. + +### Data Classes & Immutability + +**Pattern:** + +```kotlin +@Immutable +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val error: String? = null +) { + // Implicit: equals(), hashCode(), copy(), toString() +} + +// Usage +val oldStatus = RelayStatus(url, connected = false) +val newStatus = oldStatus.copy(connected = true) // Immutable update +``` + +**Key principles:** +1. **Structural equality**: `equals()` compares properties, not reference +2. **copy()**: Create modified copies without mutating +3. **All properties in constructor**: For proper `equals()`/`hashCode()` +4. **Prefer val**: Make properties immutable + +### kotlinx.collections.immutable + +**Pattern:** + +```kotlin +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +// Instead of List (which could be mutable internally) +val relays: ImmutableList = persistentListOf("wss://relay1.com", "wss://relay2.com") + +// Add returns new instance +val updated = relays.add("wss://relay3.com") // relays unchanged, updated has 3 items +``` + +**When to use:** +- Compose state that needs collection +- Publicly exposed collections +- Shared state across threads + +**See:** `references/immutability-patterns.md` + +--- + +## 4. DSL Builders + +### Type-Safe Fluent APIs + +**Mental model:** DSL (Domain-Specific Language) builders use lambda receivers and method chaining to create readable, type-safe APIs. + +**Amethyst pattern:** + +```kotlin +// TagArrayBuilder.kt:23-90 +class TagArrayBuilder { + private val tagList = mutableMapOf>() + + fun add(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList.getOrPut(tag[0], ::mutableListOf).add(tag) + return this // Method chaining + } + + fun remove(tagName: String): TagArrayBuilder { + tagList.remove(tagName) + return this // Method chaining + } + + fun build() = tagList.flatMap { it.value }.toTypedArray() +} + +// Inline function with lambda receiver (line 90) +inline fun tagArray(initializer: TagArrayBuilder.() -> Unit = {}): TagArray = + TagArrayBuilder().apply(initializer).build() +``` + +**Usage:** + +```kotlin +val tags = tagArray { + add(arrayOf("e", eventId, relay, "reply")) + add(arrayOf("p", pubkey)) + remove("a") // Remove address tags +} +``` + +**Key patterns:** +1. **Method chaining**: Return `this` from mutator methods +2. **Lambda receiver**: `TagArrayBuilder.() -> Unit` - lambda has `this: TagArrayBuilder` +3. **inline function**: Eliminates lambda overhead +4. **apply()**: Executes lambda with receiver, returns receiver + +### DSL Pattern Template + +```kotlin +class MyBuilder { + private val items = mutableListOf() + + fun add(item: Item): MyBuilder { + items.add(item) + return this + } + + fun build(): Result = Result(items.toList()) +} + +inline fun myDsl(init: MyBuilder.() -> Unit): Result = + MyBuilder().apply(init).build() + +// Usage +val result = myDsl { + add(Item("foo")) + add(Item("bar")) +} +``` + +**Why inline?** +- Eliminates lambda object allocation +- Enables `reified` type parameters +- Better performance for frequently-called DSLs + +**See:** `references/dsl-builder-examples.md` for more patterns. + +--- + +## 5. Inline Functions & reified + +### inline fun: Eliminate Overhead + +**Mental model:** `inline` copies function body to call site. No lambda object created, direct code insertion. + +**Pattern:** + +```kotlin +// Without inline +fun measureTime(block: () -> T): T { + val start = System.currentTimeMillis() + val result = block() // Lambda object allocated + println("Time: ${System.currentTimeMillis() - start}ms") + return result +} + +// With inline +inline fun measureTime(block: () -> T): T { + val start = System.currentTimeMillis() + val result = block() // No allocation, code inlined + println("Time: ${System.currentTimeMillis() - start}ms") + return result +} +``` + +**Benefits:** +1. **Zero overhead**: No lambda object allocation +2. **Non-local returns**: Can `return` from outer function inside lambda +3. **reified enabled**: Access to type parameter at runtime + +### reified: Runtime Type Access + +**Mental model:** `reified` makes generic type `T` available at runtime. Only works with `inline`. + +**Amethyst pattern:** + +```kotlin +// OptimizedJsonMapper.kt:48 +expect object OptimizedJsonMapper { + inline fun fromJsonTo(json: String): T +} + +// Usage +val event: TextNoteEvent = OptimizedJsonMapper.fromJsonTo(jsonString) +// Compiler inlines and passes TextNoteEvent::class info +``` + +**Without reified:** + +```kotlin +// Would need to pass class explicitly +fun fromJson(json: String, clazz: KClass): T { + return when (clazz) { + TextNoteEvent::class -> parseTextNote(json) as T + // ... + } +} + +val event = fromJson(json, TextNoteEvent::class) // Verbose +``` + +**With reified:** + +```kotlin +inline fun fromJson(json: String): T { + return when (T::class) { // Can access T::class! + TextNoteEvent::class -> parseTextNote(json) as T + // ... + } +} + +val event = fromJson(json) // Clean +``` + +### noinline & crossinline + +**noinline**: Prevent specific lambda from being inlined + +```kotlin +inline fun foo( + inlined: () -> Unit, + noinline notInlined: () -> Unit // Can be stored, passed around +) { + inlined() + someFunction(notInlined) // Can pass to non-inline function +} +``` + +**crossinline**: Lambda can't do non-local returns + +```kotlin +inline fun foo(crossinline block: () -> Unit) { + launch { + block() // OK: crossinline allows lambda in different context + } +} +``` + +--- + +## 6. Value Classes (Opportunity) + +**Mental model:** `value class` is a compile-time wrapper with zero runtime overhead. Single property, no boxing. + +**Not currently used in Amethyst** - potential optimization. + +**Pattern:** + +```kotlin +@JvmInline +value class EventId(val hex: String) + +@JvmInline +value class PubKey(val hex: String) + +// Type safety without runtime cost +fun fetchEvent(eventId: EventId): Event { + // eventId.hex accessed without wrapper object +} + +val id = EventId("abc123") +fetchEvent(id) // Type safe +// fetchEvent(PubKey("xyz")) // Compile error! +``` + +**When to use:** +- Type safety for primitives (IDs, hex strings, timestamps) +- High-frequency allocations (event processing) +- Clear domain types without overhead + +**Restrictions:** +- Single property only +- Must be `val` +- Can't have `init` block with logic +- Inline at compile-time, may box in some cases + +**Amethyst opportunity:** + +```kotlin +// Current (String everywhere, no type safety) +fun fetchEvent(id: String): Event // Could pass wrong string + +// With value class +@JvmInline value class EventId(val hex: String) +@JvmInline value class PubKeyHex(val hex: String) +@JvmInline value class Bech32(val encoded: String) + +fun fetchEvent(id: EventId): Event // Type safe, zero cost +``` + +--- + +## Common Patterns + +### Pattern: StateFlow State Management + +```kotlin +class MyViewModel { + private val _state = MutableStateFlow(State.Initial) + val state: StateFlow = _state.asStateFlow() + + fun loadData() { + viewModelScope.launch { + _state.value = State.Loading + val result = repository.getData() + _state.value = when (result) { + is Success -> State.Success(result.data) + is Error -> State.Error(result.message) + } + } + } +} + +sealed class State { + data object Initial : State() + data object Loading : State() + data class Success(val data: List) : State() + data class Error(val message: String) : State() +} +``` + +### Pattern: Sealed Result with Generics + +```kotlin +sealed interface Result { + data class Success(val value: T) : Result + data class Error(val exception: Exception) : Result + data object Loading : Result +} + +// Use with variance +fun fetchData(): Result = ... + +val userResult: Result = fetchData() +val itemResult: Result> = fetchData() +``` + +### Pattern: Immutable Event Builder + +```kotlin +@Immutable +data class Event( + val id: String, + val kind: Int, + val content: String, + val tags: ImmutableList +) { + companion object { + fun builder() = EventBuilder() + } +} + +class EventBuilder { + private var id: String = "" + private var kind: Int = 1 + private var content: String = "" + private val tags = mutableListOf() + + fun id(value: String) = apply { id = value } + fun kind(value: Int) = apply { kind = value } + fun content(value: String) = apply { content = value } + fun tag(tag: Tag) = apply { tags.add(tag) } + + fun build() = Event(id, kind, content, tags.toImmutableList()) +} + +// Usage +val event = Event.builder() + .id("abc") + .kind(1) + .content("Hello") + .tag(Tag.P("pubkey")) + .build() +``` + +--- + +## Delegation Guide + +**When to delegate:** + +| Topic | Delegate To | This Skill Covers | +|-------|-------------|-------------------| +| Structured concurrency, channels | kotlin-coroutines agent | Flow state patterns only | +| expect/actual, source sets | kotlin-multiplatform skill | Platform-agnostic Kotlin | +| General Compose patterns | compose-expert skill | @Immutable for performance | +| Build configuration | gradle-expert skill | - | + +**Ask kotlin-coroutines agent for:** +- Advanced Flow operators (flatMapLatest, combine, zip) +- Channel patterns +- Structured concurrency (supervisorScope, coroutineScope) +- Error handling in coroutines + +**This skill teaches:** +- StateFlow/SharedFlow state management +- Sealed hierarchies +- @Immutable for Compose +- DSL builders +- Inline/reified patterns + +--- + +## Anti-Patterns + +❌ **Mutable public state:** +```kotlin +val accountState: MutableStateFlow // BAD +``` + +✅ **Immutable public interface:** +```kotlin +val accountState: StateFlow = _accountState.asStateFlow() +``` + +--- + +❌ **Sealed class for generic results:** +```kotlin +sealed class Result { // BAD: Can't use variance + data class Success(val value: T) : Result() +} +``` + +✅ **Sealed interface for generics:** +```kotlin +sealed interface Result { // GOOD: Covariance + data class Success(val value: T) : Result +} +``` + +--- + +❌ **Mutable properties in @Immutable class:** +```kotlin +@Immutable +data class Event( + var content: String // BAD: var breaks immutability +) +``` + +✅ **All val:** +```kotlin +@Immutable +data class Event( + val content: String +) +``` + +--- + +❌ **Passing class explicitly when reified available:** +```kotlin +inline fun parse(json: String, clazz: KClass): T // BAD +``` + +✅ **Use reified:** +```kotlin +inline fun parse(json: String): T // GOOD +``` + +--- + +## Quick Reference + +### Flow Decision Tree + +``` +Need to expose state? + YES → StateFlow (always has value, single latest) + NO → Need events? → SharedFlow (optional replay, broadcast) + +Need to mutate? + Internal only → MutableStateFlow (private) + Expose publicly → StateFlow via .asStateFlow() +``` + +### Sealed Decision Tree + +``` +Need common data in base type? + YES → sealed class + NO → sealed interface + +Need generics with variance? + YES → sealed interface + NO → Either works + +Need multiple inheritance? + YES → sealed interface + NO → Either works +``` + +### Inline Decision Tree + +``` +Passing lambda to function? + Called frequently? → inline (performance) + Need reified? → inline (required) + Need to store/pass lambda? → regular fun (can't inline) +``` + +--- + +## Resources + +### Official Docs +- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) +- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html) +- [Inline Functions | Kotlin Docs](https://kotlinlang.org/docs/inline-functions.html) + +### Bundled References +- `references/flow-patterns.md` - StateFlow/SharedFlow examples from AccountManager, RelayManager +- `references/sealed-class-catalog.md` - All sealed types in quartz +- `references/dsl-builder-examples.md` - TagArrayBuilder, other DSL patterns +- `references/immutability-patterns.md` - @Immutable usage, data classes, collections + +### Codebase Examples +- AccountManager.kt:36-50 - sealed class AccountState, StateFlow pattern +- RelayConnectionManager.kt:44-52 - StateFlow state management +- SignerResult.kt:25-46 - sealed interface with generics +- TextNoteEvent.kt:51-63 - @Immutable event class +- TagArrayBuilder.kt:23-90 - DSL builder pattern, inline function +- OptimizedJsonMapper.kt:48 - inline fun with reified + +--- + +**Version:** 1.0.0 +**Last Updated:** 2025-12-30 +**Codebase Reference:** AmethystMultiplatform commit 258c4e011 diff --git a/.claude/skills/kotlin-expert/references/dsl-builder-examples.md b/.claude/skills/kotlin-expert/references/dsl-builder-examples.md new file mode 100644 index 000000000..231b82d45 --- /dev/null +++ b/.claude/skills/kotlin-expert/references/dsl-builder-examples.md @@ -0,0 +1,602 @@ +# DSL Builder Examples + +Type-safe fluent APIs and DSL patterns from the codebase. + +## Table of Contents +- [TagArrayBuilder Pattern](#tagarraybuilder-pattern) +- [Builder Variations](#builder-variations) +- [DSL Principles](#dsl-principles) +- [Creating Custom DSLs](#creating-custom-dsls) + +--- + +## TagArrayBuilder Pattern + +### Core Implementation + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt:23-91` + +```kotlin +class TagArrayBuilder { + private val tagList = mutableMapOf>() + + fun remove(tagName: String): TagArrayBuilder { + tagList.remove(tagName) + return this // Method chaining + } + + fun remove(tagName: String, tagValue: String): TagArrayBuilder { + tagList[tagName]?.removeAll { it.valueOrNull() == tagValue } + if (tagList[tagName]?.isEmpty() == true) { + tagList.remove(tagName) + } + return this + } + + fun removeIf( + predicate: (Tag, Tag) -> Boolean, + toCompare: Tag + ): TagArrayBuilder { + val tagName = toCompare.nameOrNull() ?: return this + tagList[tagName]?.removeAll { predicate(it, toCompare) } + if (tagList[tagName]?.isEmpty() == true) { + tagList.remove(tagName) + } + return this + } + + fun add(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList.getOrPut(tag[0], ::mutableListOf).add(tag) + return this + } + + fun addFirst(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList.getOrPut(tag[0], ::mutableListOf).add(0, tag) + return this + } + + fun addUnique(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList[tag[0]] = mutableListOf(tag) // Replace existing + return this + } + + fun addAll(tag: List>): TagArrayBuilder { + tag.forEach(::add) + return this + } + + fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray() + + fun build() = toTypedArray() +} + +// Inline DSL function with lambda receiver +inline fun tagArray( + initializer: TagArrayBuilder.() -> Unit = {} +): TagArray = TagArrayBuilder().apply(initializer).build() +``` + +### Usage Examples + +**Basic usage:** + +```kotlin +val tags = tagArray { + add(arrayOf("e", eventId, relay, "reply")) + add(arrayOf("p", pubkey)) + add(arrayOf("t", "bitcoin")) +} +``` + +**Advanced patterns:** + +```kotlin +// Remove and add +val tags = tagArray { + addAll(existingTags) + remove("a") // Remove all address tags + addUnique(arrayOf("client", "Amethyst")) // Replace client tag +} + +// Conditional building +val tags = tagArray { + add(arrayOf("e", rootId, "", "root")) + + if (replyToId != null) { + add(arrayOf("e", replyToId, "", "reply")) + } + + mentionedPubkeys.forEach { pubkey -> + add(arrayOf("p", pubkey)) + } + + hashtags.forEach { tag -> + add(arrayOf("t", tag.lowercase())) + } +} + +// Custom predicate removal +val tags = tagArray { + addAll(originalTags) + removeIf( + predicate = { tag, compare -> tag[1] == compare[1] }, + toCompare = arrayOf("e", eventIdToRemove) + ) +} +``` + +--- + +## Builder Variations + +### PrivateTagArrayBuilder + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt` + +```kotlin +class PrivateTagArrayBuilder { + private val builder = TagArrayBuilder() + + fun add(tag: PrivateTag): PrivateTagArrayBuilder { + builder.add(tag.toArray()) + return this + } + + fun addAll(tags: List): PrivateTagArrayBuilder { + tags.forEach { add(it) } + return this + } + + fun build(): Array> = builder.build() +} + +// DSL function +inline fun privateTagArray( + initializer: PrivateTagArrayBuilder.() -> Unit +): Array> = PrivateTagArrayBuilder().apply(initializer).build() +``` + +**Usage:** + +```kotlin +val privateTags = privateTagArray { + add(PrivateTag.Event(eventId, marker = "bookmark")) + add(PrivateTag.Profile(pubkey)) + addAll(existingPrivateTags) +} +``` + +### TlvBuilder + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt` + +```kotlin +class TlvBuilder { + private val entries = mutableListOf() + + fun add(type: TlvType, value: ByteArray): TlvBuilder { + entries.add(TlvEntry(type, value)) + return this + } + + fun addRelay(relay: String): TlvBuilder { + add(TlvType.Relay, relay.encodeToByteArray()) + return this + } + + fun addAuthor(pubkey: ByteArray): TlvBuilder { + add(TlvType.Author, pubkey) + return this + } + + fun addKind(kind: Int): TlvBuilder { + add(TlvType.Kind, kind.toByteArray()) + return this + } + + fun build(): ByteArray { + return entries.flatMap { it.encode() }.toByteArray() + } +} + +fun tlv(init: TlvBuilder.() -> Unit): ByteArray = + TlvBuilder().apply(init).build() +``` + +**Usage:** + +```kotlin +val tlvData = tlv { + addAuthor(pubkeyBytes) + addRelay("wss://relay.damus.io") + addKind(1) +} +``` + +### MapOfSetBuilder + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt` + +```kotlin +class MapOfSetBuilder { + private val map = mutableMapOf>() + + fun add(key: K, value: V): MapOfSetBuilder { + map.getOrPut(key) { mutableSetOf() }.add(value) + return this + } + + fun addAll(key: K, values: Collection): MapOfSetBuilder { + map.getOrPut(key) { mutableSetOf() }.addAll(values) + return this + } + + fun remove(key: K, value: V): MapOfSetBuilder { + map[key]?.remove(value) + if (map[key]?.isEmpty() == true) { + map.remove(key) + } + return this + } + + fun build(): Map> = map.mapValues { it.value.toSet() } +} + +inline fun mapOfSets( + init: MapOfSetBuilder.() -> Unit +): Map> = MapOfSetBuilder().apply(init).build() +``` + +**Usage:** + +```kotlin +val relayMap = mapOfSets { + add("wss://relay1.com", eventId1) + add("wss://relay1.com", eventId2) + add("wss://relay2.com", eventId3) +} +// Result: {"wss://relay1.com": [eventId1, eventId2], "wss://relay2.com": [eventId3]} +``` + +--- + +## DSL Principles + +### 1. Lambda with Receiver + +**Mental model:** Lambda receiver makes `this` refer to builder instance inside lambda. + +```kotlin +// Without receiver +fun buildTags(config: (TagArrayBuilder) -> Unit) { + val builder = TagArrayBuilder() + config(builder) // Must pass builder explicitly + builder.build() +} + +buildTags { builder -> + builder.add(...) // Verbose +} + +// With receiver +inline fun buildTags(config: TagArrayBuilder.() -> Unit) { + TagArrayBuilder().apply(config).build() +} + +buildTags { + add(...) // Clean - 'this' is builder +} +``` + +### 2. Method Chaining + +**Pattern:** Return `this` from mutator methods. + +```kotlin +class Builder { + private var value: String = "" + + fun setValue(v: String): Builder { + value = v + return this // Enable chaining + } + + fun append(s: String): Builder { + value += s + return this + } + + fun build(): String = value +} + +// Usage +val result = Builder() + .setValue("Hello") + .append(" ") + .append("World") + .build() +``` + +### 3. Inline for Performance + +**Why inline:** +- Eliminates lambda allocation +- Allows `reified` type parameters +- Better for hot paths (frequently called) + +```kotlin +// NOT inline - lambda object created each call +fun myDsl(init: Builder.() -> Unit): Result { + return Builder().apply(init).build() +} + +// Inline - lambda code inlined at call site +inline fun myDsl(init: Builder.() -> Unit): Result { + return Builder().apply(init).build() +} +``` + +### 4. Type Safety + +**Use generics for compile-time safety:** + +```kotlin +// Type-safe builder +class EventBuilder { + fun addTag(tag: Tag): EventBuilder { // Only accepts tags for this event type + tags.add(tag) + return this + } +} + +// Usage +val textNote = EventBuilder() + .addTag(TextNoteTag.Subject("Hello")) // OK + // .addTag(ChannelTag.Name("test")) // Compile error! + .build() +``` + +--- + +## Creating Custom DSLs + +### Pattern: Simple Builder DSL + +```kotlin +class QueryBuilder { + private val filters = mutableListOf() + private var limit: Int? = null + private var offset: Int? = null + + fun filter(field: String, value: String): QueryBuilder { + filters.add("$field:$value") + return this + } + + fun limit(n: Int): QueryBuilder { + limit = n + return this + } + + fun offset(n: Int): QueryBuilder { + offset = n + return this + } + + fun build(): String { + val parts = mutableListOf() + if (filters.isNotEmpty()) { + parts.add(filters.joinToString(" AND ")) + } + if (limit != null) { + parts.add("LIMIT $limit") + } + if (offset != null) { + parts.add("OFFSET $offset") + } + return parts.joinToString(" ") + } +} + +inline fun query(init: QueryBuilder.() -> Unit): String = + QueryBuilder().apply(init).build() + +// Usage +val sql = query { + filter("status", "active") + filter("age", ">18") + limit(10) + offset(20) +} +// Result: "status:active AND age:>18 LIMIT 10 OFFSET 20" +``` + +### Pattern: Nested Builders + +```kotlin +class FilterBuilder { + private val conditions = mutableListOf() + + fun equals(field: String, value: String) { + conditions.add("$field = '$value'") + } + + fun greaterThan(field: String, value: Int) { + conditions.add("$field > $value") + } + + fun build(): String = conditions.joinToString(" AND ") +} + +class QueryBuilder { + private var filterClause: String = "" + private var selectClause: String = "*" + + fun select(vararg fields: String): QueryBuilder { + selectClause = fields.joinToString(", ") + return this + } + + fun where(init: FilterBuilder.() -> Unit): QueryBuilder { + filterClause = FilterBuilder().apply(init).build() + return this + } + + fun build(): String { + return "SELECT $selectClause WHERE $filterClause" + } +} + +inline fun query(init: QueryBuilder.() -> Unit): String = + QueryBuilder().apply(init).build() + +// Usage +val sql = query { + select("id", "name", "age") + where { + equals("status", "active") + greaterThan("age", 18) + } +} +// Result: "SELECT id, name, age WHERE status = 'active' AND age > 18" +``` + +### Pattern: Type-Safe HTML DSL + +```kotlin +abstract class Tag(val name: String) { + private val children = mutableListOf() + private val attributes = mutableMapOf() + + fun tag(tag: T, init: T.() -> Unit): T { + tag.init() + children.add(tag) + return tag + } + + fun attr(name: String, value: String) { + attributes[name] = value + } + + fun render(builder: StringBuilder, indent: String) { + builder.append("$indent<$name") + attributes.forEach { (k, v) -> builder.append(" $k=\"$v\"") } + if (children.isEmpty()) { + builder.append("/>\n") + } else { + builder.append(">\n") + children.forEach { it.render(builder, "$indent ") } + builder.append("$indent\n") + } + } +} + +class HTML : Tag("html") +class Head : Tag("head") +class Body : Tag("body") +class Div : Tag("div") +class P : Tag("p") +class A : Tag("a") + +fun HTML.head(init: Head.() -> Unit) = tag(Head(), init) +fun HTML.body(init: Body.() -> Unit) = tag(Body(), init) +fun Body.div(init: Div.() -> Unit) = tag(Div(), init) +fun Div.p(init: P.() -> Unit) = tag(P(), init) +fun Div.a(init: A.() -> Unit) = tag(A(), init) + +fun html(init: HTML.() -> Unit): HTML = HTML().apply(init) + +// Usage +val page = html { + head { + // ... + } + body { + div { + attr("class", "container") + p { + attr("id", "intro") + } + a { + attr("href", "https://example.com") + } + } + } +} +``` + +--- + +## Best Practices + +### ✅ DO + +1. **Return `this` for chaining:** + ```kotlin + fun add(item: Item): Builder { + items.add(item) + return this + } + ``` + +2. **Use `inline` for DSL functions:** + ```kotlin + inline fun myDsl(init: Builder.() -> Unit) = Builder().apply(init).build() + ``` + +3. **Provide sensible defaults:** + ```kotlin + inline fun query( + init: QueryBuilder.() -> Unit = {} // Empty lambda as default + ) = QueryBuilder().apply(init).build() + ``` + +4. **Validate in `build()`:** + ```kotlin + fun build(): Result { + require(fields.isNotEmpty()) { "Must specify at least one field" } + return Result(fields) + } + ``` + +### ❌ DON'T + +1. **Forget to return `this`:** + ```kotlin + fun add(item: Item) { // BAD: Can't chain + items.add(item) + } + ``` + +2. **Mutate after build:** + ```kotlin + val builder = Builder() + builder.add("foo") + val result = builder.build() + builder.add("bar") // BAD: Confusing state + ``` + +3. **Expose mutable state:** + ```kotlin + class Builder { + val items = mutableListOf() // BAD: Can be mutated externally + } + ``` + +4. **Make DSL functions non-inline unnecessarily:** + ```kotlin + fun myDsl(init: Builder.() -> Unit) = ... // BAD: Lambda allocation overhead + ``` + +--- + +## References + +- TagArrayBuilder.kt:23-91 +- PrivateTagArrayBuilder.kt +- TlvBuilder.kt +- [Type-Safe Builders | Kotlin Docs](https://kotlinlang.org/docs/type-safe-builders.html) +- [DSLs with Kotlin](https://kt.academy/article/dsl-intro) diff --git a/.claude/skills/kotlin-expert/references/flow-patterns.md b/.claude/skills/kotlin-expert/references/flow-patterns.md new file mode 100644 index 000000000..4a15b1d91 --- /dev/null +++ b/.claude/skills/kotlin-expert/references/flow-patterns.md @@ -0,0 +1,405 @@ +# Flow Patterns in Amethyst + +StateFlow and SharedFlow usage patterns from the codebase. + +## Table of Contents +- [StateFlow for State Management](#stateflow-for-state-management) +- [Flow Composition](#flow-composition) +- [Common Patterns](#common-patterns) +- [Anti-Patterns](#anti-patterns) + +--- + +## StateFlow for State Management + +### AccountManager Pattern + +**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-115` + +```kotlin +sealed class AccountState { + data object LoggedOut : AccountState() + + data class LoggedIn( + val signer: NostrSigner, + val pubKeyHex: String, + val npub: String, + val nsec: String?, + val isReadOnly: Boolean, + ) : AccountState() +} + +class AccountManager { + private val _accountState = MutableStateFlow(AccountState.LoggedOut) + val accountState: StateFlow = _accountState.asStateFlow() + + fun generateNewAccount(): AccountState.LoggedIn { + val keyPair = KeyPair() + val signer = NostrSignerInternal(keyPair) + + val state = AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false + ) + _accountState.value = state // Update state + return state + } + + fun loginWithKey(keyInput: String): Result { + // ... validation ... + + val state = AccountState.LoggedIn(...) + _accountState.value = state + return Result.success(state) + } + + fun logout() { + _accountState.value = AccountState.LoggedOut + } +} +``` + +**Pattern highlights:** +- Private `MutableStateFlow` for internal mutations +- Public `StateFlow` via `.asStateFlow()` for read-only access +- Sealed class for type-safe state variants +- Initial value required (`AccountState.LoggedOut`) + +### RelayConnectionManager Pattern + +**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt:44-80` + +```kotlin +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val error: String? = null, + val messageCount: Int = 0 +) + +open class RelayConnectionManager( + websocketBuilder: WebsocketBuilder +) : IRelayClientListener { + private val client = NostrClient(websocketBuilder) + + // Map of relay URLs to their status + private val _relayStatuses = MutableStateFlow>(emptyMap()) + val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() + + // Delegated StateFlows from client + val connectedRelays: StateFlow> = client.connectedRelaysFlow() + val availableRelays: StateFlow> = client.availableRelaysFlow() + + fun addRelay(url: String): NormalizedRelayUrl? { + val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null + updateRelayStatus(normalized) { it.copy(connected = false, error = null) } + return normalized + } + + fun removeRelay(url: NormalizedRelayUrl) { + _relayStatuses.value = _relayStatuses.value - url // Immutable update (remove from map) + } + + private fun updateRelayStatus( + relay: NormalizedRelayUrl, + update: (RelayStatus) -> RelayStatus + ) { + _relayStatuses.value = _relayStatuses.value.toMutableMap().apply { + val current = get(relay) ?: RelayStatus(relay, false) + put(relay, update(current)) + } + } + + // IRelayClientListener implementation + override fun onConnect(relay: NormalizedRelayUrl) { + updateRelayStatus(relay) { it.copy(connected = true, error = null) } + } + + override fun onError(relay: NormalizedRelayUrl, error: String) { + updateRelayStatus(relay) { it.copy(connected = false, error = error) } + } +} +``` + +**Pattern highlights:** +- `Map` as state value for collection tracking +- Immutable map updates (copy with modifications) +- Helper function `updateRelayStatus` for consistent updates +- Delegation pattern (client exposes its own StateFlows) + +--- + +## Flow Composition + +### Multiple StateFlows in UI + +**Pattern:** + +```kotlin +@Composable +fun LoginScreen(accountManager: AccountManager) { + val accountState by accountManager.accountState.collectAsState() + + when (accountState) { + is AccountState.LoggedOut -> { + LoginForm(onLogin = { key -> accountManager.loginWithKey(key) }) + } + is AccountState.LoggedIn -> { + MainApp(account = accountState as AccountState.LoggedIn) + } + } +} +``` + +### Observing Multiple Flows + +**Pattern:** + +```kotlin +@Composable +fun RelayStatusCard(relayManager: RelayConnectionManager) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() + + Column { + Text("${connectedRelays.size} of ${relayStatuses.size} relays connected") + + relayStatuses.forEach { (url, status) -> + RelayRow( + url = url, + connected = status.connected, + error = status.error + ) + } + } +} +``` + +--- + +## Common Patterns + +### Pattern: Immutable State Updates + +```kotlin +// Map updates +_relayStatuses.value = _relayStatuses.value + (url to newStatus) // Add +_relayStatuses.value = _relayStatuses.value - url // Remove +_relayStatuses.value = _relayStatuses.value.mapValues { (key, value) -> + if (key == targetUrl) value.copy(connected = true) else value +} + +// List updates +_items.value = _items.value + newItem // Append +_items.value = _items.value.filter { it.id != removedId } // Remove +_items.value = _items.value.map { if (it.id == id) it.copy(name = newName) else it } // Update + +// Object updates +_user.value = _user.value.copy(name = newName) +``` + +### Pattern: Conditional State Transitions + +```kotlin +fun attemptLogin(credentials: Credentials) { + if (_loginState.value is LoginState.LoggingIn) { + return // Already logging in, ignore + } + + _loginState.value = LoginState.LoggingIn + viewModelScope.launch { + try { + val user = repository.login(credentials) + _loginState.value = LoginState.Success(user) + } catch (e: Exception) { + _loginState.value = LoginState.Error(e.message ?: "Login failed") + } + } +} +``` + +### Pattern: Derived State + +```kotlin +class MyViewModel { + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + // Derived state (computed from items) + val itemCount: StateFlow = items.map { it.size } + .stateIn(viewModelScope, SharingStarted.Lazily, 0) + + val hasItems: StateFlow = items.map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.Lazily, false) +} + +// Usage in Compose +@Composable +fun ItemList(viewModel: MyViewModel) { + val itemCount by viewModel.itemCount.collectAsState() + val hasItems by viewModel.hasItems.collectAsState() + + if (hasItems) { + Text("$itemCount items") + } else { + Text("No items") + } +} +``` + +### Pattern: State with Loading/Error + +```kotlin +sealed class UiState { + data object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val message: String) : UiState() +} + +class FeedViewModel { + private val _feedState = MutableStateFlow>>(UiState.Loading) + val feedState: StateFlow>> = _feedState.asStateFlow() + + fun loadFeed() { + viewModelScope.launch { + _feedState.value = UiState.Loading + try { + val events = repository.getEvents() + _feedState.value = UiState.Success(events) + } catch (e: Exception) { + _feedState.value = UiState.Error(e.message ?: "Unknown error") + } + } + } +} + +// UI +@Composable +fun FeedScreen(viewModel: FeedViewModel) { + val state by viewModel.feedState.collectAsState() + + when (state) { + is UiState.Loading -> LoadingSpinner() + is UiState.Success -> EventList((state as UiState.Success).data) + is UiState.Error -> ErrorMessage((state as UiState.Error).message) + } +} +``` + +--- + +## Anti-Patterns + +### ❌ Exposing Mutable State + +```kotlin +// BAD: External code can mutate +class BadViewModel { + val state: MutableStateFlow = MutableStateFlow(State.Initial) +} + +// Caller can do: +viewModel.state.value = State.Hacked // Bypass internal logic! +``` + +### ✅ Expose Immutable + +```kotlin +// GOOD: Only ViewModel can mutate +class GoodViewModel { + private val _state = MutableStateFlow(State.Initial) + val state: StateFlow = _state.asStateFlow() + + fun updateState(newState: State) { + // Controlled mutation with validation + _state.value = newState + } +} +``` + +--- + +### ❌ Not Using Immutable Updates + +```kotlin +// BAD: Mutating collection doesn't trigger StateFlow update +val list = mutableListOf() +list.add(newItem) +_items.value = list // Same reference, no update emitted! +``` + +### ✅ Create New Instance + +```kotlin +// GOOD: New list instance +_items.value = _items.value + newItem // New list created, update emitted +``` + +--- + +### ❌ StateFlow for Events + +```kotlin +// BAD: Events get lost if no collector +class BadViewModel { + val navigationEvent: StateFlow = MutableStateFlow(null) + + fun navigate(event: NavEvent) { + _navigationEvent.value = event // Lost if UI not observing! + } +} +``` + +### ✅ SharedFlow for Events + +```kotlin +// GOOD: Events queued +class GoodViewModel { + private val _navigationEvent = MutableSharedFlow(replay = 0) + val navigationEvent: SharedFlow = _navigationEvent.asSharedFlow() + + fun navigate(event: NavEvent) { + viewModelScope.launch { + _navigationEvent.emit(event) // Queued for collector + } + } +} +``` + +--- + +### ❌ Blocking Operations in State Update + +```kotlin +// BAD: Blocking main thread +fun loadData() { + _state.value = fetchDataFromNetwork() // Blocks! +} +``` + +### ✅ Async Updates + +```kotlin +// GOOD: Use coroutines +fun loadData() { + viewModelScope.launch { + _state.value = UiState.Loading + val data = withContext(Dispatchers.IO) { + fetchDataFromNetwork() + } + _state.value = UiState.Success(data) + } +} +``` + +--- + +## References + +- AccountManager.kt:36-115 +- RelayConnectionManager.kt:44-80 +- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) +- [Hot vs Cold Flows](https://carrion.dev/en/posts/kotlin-flows-hot-cold/) diff --git a/.claude/skills/kotlin-expert/references/immutability-patterns.md b/.claude/skills/kotlin-expert/references/immutability-patterns.md new file mode 100644 index 000000000..f4c076669 --- /dev/null +++ b/.claude/skills/kotlin-expert/references/immutability-patterns.md @@ -0,0 +1,641 @@ +# Immutability Patterns + +@Immutable annotation, data classes, and immutable collections for Compose performance. + +## Table of Contents +- [Why Immutability Matters](#why-immutability-matters) +- [@Immutable Annotation](#immutable-annotation) +- [Data Classes](#data-classes) +- [Immutable Collections](#immutable-collections) +- [Common Patterns](#common-patterns) +- [Performance Impact](#performance-impact) + +--- + +## Why Immutability Matters + +### Compose Recomposition + +**Mental model:** Compose tracks state changes by comparing references. If an `@Immutable` object reference doesn't change, Compose skips recomposition. + +```kotlin +// Without @Immutable - Recomposes on every parent recomposition +data class User(val name: String, val age: Int) + +@Composable +fun UserCard(user: User) { // Recomposes unnecessarily + Text(user.name) +} + +// With @Immutable - Only recomposes when user reference changes +@Immutable +data class User(val name: String, val age: Int) + +@Composable +fun UserCard(user: User) { // Smart recomposition + Text(user.name) +} +``` + +**Performance difference:** +- Without `@Immutable`: 1000 `UserCard` recompositions per screen update +- With `@Immutable`: 10 `UserCard` recompositions (only changed users) + +### Thread Safety + +Immutable objects are inherently thread-safe: + +```kotlin +@Immutable +data class Event( + val id: String, + val content: String, + val createdAt: Long +) + +// Safe to share across coroutines without synchronization +val sharedEvent: Event = fetchEvent() +launch { processEvent(sharedEvent) } // Safe +launch { saveEvent(sharedEvent) } // Safe +``` + +--- + +## @Immutable Annotation + +### Basic Usage + +**Pattern from Amethyst:** + +```kotlin +// TextNoteEvent.kt:51-63 +@Immutable +class TextNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + // All properties are val (immutable) + // No var properties + // No mutable collections +} +``` + +**Requirements for @Immutable:** +1. All properties must be `val` (no `var`) +2. All property types must be immutable or primitives +3. No mutable collections (`MutableList`, `MutableMap`) +4. Arrays are allowed (treated as immutable by contract) +5. No public mutable state + +### @Immutable vs @Stable + +**@Immutable:** Value never changes after construction + +```kotlin +@Immutable +data class User(val name: String, val age: Int) +// Once created, user.name and user.age never change +``` + +**@Stable:** Value can change, but changes are tracked + +```kotlin +@Stable +class MutableCounter { + var count by mutableStateOf(0) // Changes tracked by Compose +} +``` + +**Amethyst uses @Immutable extensively:** +- 173+ event classes annotated with `@Immutable` +- All Nostr events immutable by design +- Critical for feed performance (thousands of events) + +--- + +## Data Classes + +### Immutable Data Classes + +**Pattern:** + +```kotlin +@Immutable +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val error: String? = null, + val messageCount: Int = 0 +) { + // Immutable properties only (val) + // Default values allowed +} +``` + +**Benefits:** +1. **Structural equality:** `equals()` compares values, not references +2. **copy():** Create modified copies without mutation +3. **toString():** Debugging-friendly output +4. **hashCode():** Consistent hashing for collections +5. **componentN():** Destructuring support + +### copy() for Updates + +**Mental model:** Instead of mutating, create modified copies. + +```kotlin +val status = RelayStatus( + url = "wss://relay.damus.io", + connected = false, + error = null +) + +// Immutable update +val updatedStatus = status.copy(connected = true) + +// Original unchanged +assert(status.connected == false) +assert(updatedStatus.connected == true) +``` + +**StateFlow pattern:** + +```kotlin +private val _relayStatuses = MutableStateFlow>(emptyMap()) + +fun updateRelay(url: String, connected: Boolean) { + _relayStatuses.value = _relayStatuses.value.mapValues { (key, status) -> + if (key == url) { + status.copy(connected = connected) // Immutable update + } else { + status + } + } +} +``` + +### All Properties in Constructor + +**Why important for data classes:** + +```kotlin +// BAD: Properties outside constructor not included in equals/hashCode +data class User(val name: String) { + var age: Int = 0 // NOT in equals/hashCode/copy! +} + +val user1 = User("Alice") +val user2 = User("Alice") +user1.age = 25 +user2.age = 30 + +assert(user1 == user2) // TRUE! age not compared +assert(user1.copy() == user1) // TRUE! age not copied + +// GOOD: All properties in constructor +@Immutable +data class User( + val name: String, + val age: Int // Included in equals/hashCode/copy +) +``` + +--- + +## Immutable Collections + +### kotlinx.collections.immutable + +**Installation:** + +```kotlin +// build.gradle.kts +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7") +} +``` + +**Why use:** +- Structural sharing (efficient copies) +- Explicit immutability (compiler enforced) +- Safe for Compose state + +### ImmutableList + +```kotlin +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +// Create immutable list +val relays: ImmutableList = persistentListOf( + "wss://relay1.com", + "wss://relay2.com" +) + +// Add returns NEW list +val updated = relays.add("wss://relay3.com") +assert(relays.size == 2) // Original unchanged +assert(updated.size == 3) // New list has 3 items + +// Convert from regular list +val mutableList = mutableListOf("a", "b", "c") +val immutable = mutableList.toImmutableList() +``` + +### ImmutableMap + +```kotlin +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap + +// Create immutable map +val relayStatuses: ImmutableMap = persistentMapOf( + "wss://relay1.com" to RelayStatus(...), + "wss://relay2.com" to RelayStatus(...) +) + +// Put returns NEW map +val updated = relayStatuses.put("wss://relay3.com", RelayStatus(...)) + +// Remove returns NEW map +val removed = relayStatuses.remove("wss://relay1.com") +``` + +### ImmutableSet + +```kotlin +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +val connectedRelays: ImmutableSet = persistentSetOf( + "wss://relay1.com", + "wss://relay2.com" +) + +val updated = connectedRelays.add("wss://relay3.com") +``` + +### Structural Sharing + +**Mental model:** Immutable collections reuse internal structure for efficiency. + +```kotlin +val list1 = persistentListOf(1, 2, 3, 4, 5) // 5 items +val list2 = list1.add(6) // Shares structure with list1 + +// Internally: +// list1 and list2 share nodes for items 1-5 +// list2 has one additional node for item 6 +// O(1) time, O(1) space for add operation +``` + +--- + +## Common Patterns + +### Pattern: Immutable State Updates + +```kotlin +@Immutable +data class FeedState( + val events: ImmutableList, + val loading: Boolean, + val error: String? +) + +class FeedViewModel { + private val _state = MutableStateFlow( + FeedState( + events = persistentListOf(), + loading = false, + error = null + ) + ) + val state: StateFlow = _state.asStateFlow() + + fun loadEvents() { + _state.value = _state.value.copy(loading = true, error = null) + + viewModelScope.launch { + try { + val events = repository.getEvents() + _state.value = _state.value.copy( + events = events.toImmutableList(), + loading = false + ) + } catch (e: Exception) { + _state.value = _state.value.copy( + loading = false, + error = e.message + ) + } + } + } + + fun addEvent(event: Event) { + _state.value = _state.value.copy( + events = _state.value.events.add(event) // Immutable add + ) + } + + fun removeEvent(eventId: String) { + _state.value = _state.value.copy( + events = _state.value.events.filter { it.id != eventId }.toImmutableList() + ) + } +} +``` + +### Pattern: Deep Immutability + +```kotlin +// Nested immutable structures +@Immutable +data class User( + val name: String, + val profile: Profile // Also immutable +) + +@Immutable +data class Profile( + val bio: String, + val avatar: String, + val relays: ImmutableList // Immutable collection +) + +// Safe deep copy +val user = User( + name = "Alice", + profile = Profile( + bio = "Nostr enthusiast", + avatar = "https://...", + relays = persistentListOf("wss://relay1.com") + ) +) + +val updatedUser = user.copy( + profile = user.profile.copy( + bio = "Bitcoin & Nostr enthusiast" // Deep update + ) +) +``` + +### Pattern: Collection Builder to Immutable + +```kotlin +// Build mutable, convert to immutable +fun processEvents(input: List): ImmutableList { + val processed = mutableListOf() + + for (event in input) { + if (event.isValid()) { + processed.add(event.normalize()) + } + } + + return processed.toImmutableList() // Convert once at end +} +``` + +### Pattern: Immutable Map Updates + +```kotlin +private val _relayStatuses = MutableStateFlow>( + persistentMapOf() +) + +fun updateRelay(url: String, connected: Boolean) { + val currentStatuses = _relayStatuses.value + val currentStatus = currentStatuses[url] ?: RelayStatus(url, false) + + _relayStatuses.value = currentStatuses.put( + url, + currentStatus.copy(connected = connected) + ) +} + +fun removeRelay(url: String) { + _relayStatuses.value = _relayStatuses.value.remove(url) +} +``` + +--- + +## Performance Impact + +### Benchmarks (Approximate) + +**Recomposition cost:** + +```kotlin +// 1000 items in LazyColumn +// Without @Immutable: ~100ms per frame (skipped frames) +// With @Immutable: ~16ms per frame (smooth 60fps) + +@Immutable +data class Item(val id: String, val name: String) + +@Composable +fun ItemList(items: ImmutableList) { + LazyColumn { + items(items, key = { it.id }) { item -> + ItemRow(item) // Only recomposes when item changes + } + } +} +``` + +**Structural sharing efficiency:** + +```kotlin +val list1 = persistentListOf(1..10000) +val list2 = list1.add(10001) // O(log n) time, shares structure + +// Regular list (copy on modification): +val mutableList = (1..10000).toMutableList() +val copy = mutableList.toList() + 10001 // O(n) time, full copy +``` + +### When to Use Immutable Collections + +**Use ImmutableList/Map/Set when:** +- Storing in Compose state (@Immutable class) +- Sharing across coroutines +- Frequent modifications (structural sharing efficient) +- Need compile-time immutability guarantee + +**Use Array when:** +- Fixed size, no modifications +- Nostr protocol (tags are `Array>`) +- Performance-critical (array access is fastest) + +**Use regular List/Map/Set when:** +- Local scope only +- Build once, read many times +- Converting to immutable at boundary + +--- + +## Anti-Patterns + +### ❌ Mutable Properties in @Immutable Class + +```kotlin +@Immutable +data class BadEvent( + val id: String, + var content: String // BAD: var breaks immutability +) +``` + +### ✅ All val Properties + +```kotlin +@Immutable +data class GoodEvent( + val id: String, + val content: String +) +``` + +--- + +### ❌ Mutable Collections in @Immutable Class + +```kotlin +@Immutable +data class BadState( + val items: MutableList // BAD: Can mutate items +) + +// Caller can mutate: +val state = BadState(mutableListOf()) +state.items.add(newItem) // Breaks immutability! +``` + +### ✅ Immutable Collections + +```kotlin +@Immutable +data class GoodState( + val items: ImmutableList +) + +// Caller must create new state: +val updated = state.copy(items = state.items.add(newItem)) +``` + +--- + +### ❌ Direct Mutation + +```kotlin +val status = RelayStatus(url, connected = false) +status.connected = true // Compile error (val) + +// But could happen with mutable nested objects: +@Immutable +data class Config( + val settings: Settings // If Settings is mutable... +) + +class Settings { + var theme: String = "dark" // BAD +} + +val config = Config(Settings()) +config.settings.theme = "light" // Mutates "immutable" config! +``` + +### ✅ Deep Immutability + +```kotlin +@Immutable +data class Config( + val settings: Settings +) + +@Immutable +data class Settings( + val theme: String // val only +) + +val config = Config(Settings("dark")) +val updated = config.copy( + settings = config.settings.copy(theme = "light") +) +``` + +--- + +### ❌ Exposing Mutable Internal State + +```kotlin +@Immutable +class BadViewModel { + private val _items = mutableListOf() + val items: List = _items // BAD: Exposes mutable list + + fun addItem(item: Item) { + _items.add(item) + } +} + +// Caller can cast and mutate: +val vm = BadViewModel() +(vm.items as MutableList).clear() // Breaks encapsulation! +``` + +### ✅ Convert to Immutable at Boundary + +```kotlin +@Immutable +class GoodViewModel { + private val _items = mutableListOf() + val items: ImmutableList + get() = _items.toImmutableList() // GOOD: Copy to immutable + + fun addItem(item: Item) { + _items.add(item) + } +} +``` + +--- + +## Checklist for Immutability + +**For @Immutable classes:** +- [ ] All properties are `val`, never `var` +- [ ] No mutable collections (`MutableList`, `MutableMap`, `MutableSet`) +- [ ] Nested objects are also `@Immutable` or primitives +- [ ] No public mutable state +- [ ] Use `copy()` for updates, never mutation +- [ ] Arrays used only when truly immutable by contract + +**For StateFlow state:** +- [ ] State class is `@Immutable` +- [ ] Use immutable collections (ImmutableList, ImmutableMap) +- [ ] Create new instances for updates (`copy()`, `.add()`, `.put()`) +- [ ] Never mutate state in-place + +**For Compose performance:** +- [ ] All `@Composable` parameters are `@Immutable` or `@Stable` +- [ ] Lists use `ImmutableList` and `key` parameter in `items()` +- [ ] Heavy objects (events, profiles) cached and reused + +--- + +## References + +- TextNoteEvent.kt:51-63 - @Immutable event example +- RelayConnectionManager.kt - Immutable map updates +- [Compose Performance | Android Developers](https://developer.android.com/jetpack/compose/performance/stability) +- [kotlinx.collections.immutable | GitHub](https://github.com/Kotlin/kotlinx.collections.immutable) +- [@Stable and @Immutable | Compose Docs](https://developer.android.com/jetpack/compose/performance/stability/fix) diff --git a/.claude/skills/kotlin-expert/references/sealed-class-catalog.md b/.claude/skills/kotlin-expert/references/sealed-class-catalog.md new file mode 100644 index 000000000..c99cbef3a --- /dev/null +++ b/.claude/skills/kotlin-expert/references/sealed-class-catalog.md @@ -0,0 +1,482 @@ +# Sealed Class Catalog + +Comprehensive list of sealed types in AmethystMultiplatform with usage patterns. + +## Table of Contents +- [State Management](#state-management) +- [Result Types](#result-types) +- [Tag Variants](#tag-variants) +- [Sealed Class vs Sealed Interface](#sealed-class-vs-sealed-interface) +- [Patterns](#patterns) + +--- + +## State Management + +### AccountState (Sealed Class) + +**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-46` + +```kotlin +sealed class AccountState { + data object LoggedOut : AccountState() + + data class LoggedIn( + val signer: NostrSigner, + val pubKeyHex: String, + val npub: String, + val nsec: String?, + val isReadOnly: Boolean + ) : AccountState() +} +``` + +**Why sealed class:** +- Two distinct states with different data +- `LoggedIn` holds data, `LoggedOut` doesn't +- No need for generics or multiple inheritance + +**Usage:** + +```kotlin +fun handleAccountState(state: AccountState) { + when (state) { + is AccountState.LoggedOut -> showLogin() + is AccountState.LoggedIn -> { + showFeed( + pubkey = state.pubKeyHex, + canSign = !state.isReadOnly + ) + } + } // Exhaustive - compiler enforces +} +``` + +### VerificationState (Sealed Class) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt` + +```kotlin +sealed class VerificationState { + data object NotStarted : VerificationState() + data object Started : VerificationState() + data class Failed(val reason: String) : VerificationState() + data object Verified : VerificationState() +} +``` + +**Pattern:** +- State machine (NotStarted → Started → Failed/Verified) +- Only `Failed` carries data (reason) +- Rest are singletons (`data object`) + +--- + +## Result Types + +### SignerResult (Sealed Interface with Generics) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt:25-46` + +```kotlin +sealed interface SignerResult { + sealed interface RequestAddressed : SignerResult { + class Successful(val result: T) : RequestAddressed + class Rejected : RequestAddressed + class TimedOut : RequestAddressed + class ReceivedButCouldNotPerform( + val message: String? = null + ) : RequestAddressed + class ReceivedButCouldNotParseEventFromResult( + val eventJson: String + ) : RequestAddressed + class ReceivedButCouldNotVerifyResultingEvent( + val invalidEvent: Event + ) : RequestAddressed + } +} + +interface IResult + +data class SignResult(val event: Event) : IResult +data class EncryptionResult(val ciphertext: String) : IResult +data class DecryptionResult(val plaintext: String) : IResult +``` + +**Why sealed interface:** +- Generic result type `` +- Nested sealed hierarchy (RequestAddressed) +- Need covariance for flexible result types + +**Usage:** + +```kotlin +suspend fun signEvent(event: Event): SignerResult { + return when (val result = remoteSigner.sign(event)) { + is SignerResult.RequestAddressed.Successful -> result + is SignerResult.RequestAddressed.Rejected -> { + logger.warn("Signing rejected") + result + } + is SignerResult.RequestAddressed.TimedOut -> { + logger.error("Signing timed out") + result + } + is SignerResult.RequestAddressed.ReceivedButCouldNotPerform -> { + logger.error("Signer error: ${result.message}") + result + } + } +} +``` + +### CacheResults (Sealed Class with Generics) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt` + +```kotlin +sealed class CacheResults { + data class Found(val value: T) : CacheResults() + class NotFound : CacheResults() +} +``` + +**Pattern:** +- Simple binary result (found/not found) +- `Found` carries data, `NotFound` doesn't +- Generic for reusability + +--- + +## Tag Variants + +### MuteTag (Sealed Class) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt` + +```kotlin +sealed class MuteTag( + val nameOrNull: String?, + val valueOrNull: String? +) { + class Event(eventId: String) : MuteTag("e", eventId) + class Profile(pubkey: String) : MuteTag("p", pubkey) + class Word(word: String) : MuteTag("word", word) + class Thread(threadId: String) : MuteTag("thread", threadId) + + companion object { + fun parse(tag: Array): MuteTag? { + return when (tag.getOrNull(0)) { + "e" -> tag.getOrNull(1)?.let { Event(it) } + "p" -> tag.getOrNull(1)?.let { Profile(it) } + "word" -> tag.getOrNull(1)?.let { Word(it) } + "thread" -> tag.getOrNull(1)?.let { Thread(it) } + else -> null + } + } + } + + fun toArray(): Array { + return arrayOf(nameOrNull ?: "", valueOrNull ?: "") + } +} +``` + +**Pattern:** +- Common base class with shared properties +- Each variant represents different tag type +- Factory method `parse()` for parsing +- `toArray()` for serialization + +### BookmarkIdTag (Sealed Class) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt` + +```kotlin +sealed class BookmarkIdTag { + abstract val id: String + abstract val marker: String? + + data class Event(override val id: String, override val marker: String?) : BookmarkIdTag() + data class Profile(override val id: String, override val marker: String?) : BookmarkIdTag() + data class Address(override val id: String, override val marker: String?) : BookmarkIdTag() + + companion object { + fun parse(tag: Array): BookmarkIdTag? { + val marker = tag.getOrNull(3) + return when (tag.getOrNull(0)) { + "e" -> tag.getOrNull(1)?.let { Event(it, marker) } + "p" -> tag.getOrNull(1)?.let { Profile(it, marker) } + "a" -> tag.getOrNull(1)?.let { Address(it, marker) } + else -> null + } + } + } +} +``` + +**Pattern:** +- Abstract properties in sealed class +- Data classes implement abstract properties +- Parse factory returns sealed variant + +--- + +## Exception Hierarchies + +### SignerExceptions (Sealed Class) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt` + +```kotlin +sealed class SignerExceptions(message: String) : Exception(message) { + class UnableToSign(message: String) : SignerExceptions(message) + class UnableToDecrypt(message: String) : SignerExceptions(message) + class UnableToEncrypt(message: String) : SignerExceptions(message) + class UnableToGetPublicKey(message: String) : SignerExceptions(message) +} +``` + +**Pattern:** +- Sealed exception hierarchy +- Extends `Exception` base class +- Type-safe error handling + +**Usage:** + +```kotlin +try { + signer.sign(event) +} catch (e: SignerExceptions) { + when (e) { + is SignerExceptions.UnableToSign -> logger.error("Signing failed: ${e.message}") + is SignerExceptions.UnableToDecrypt -> logger.error("Decryption failed: ${e.message}") + is SignerExceptions.UnableToEncrypt -> logger.error("Encryption failed: ${e.message}") + is SignerExceptions.UnableToGetPublicKey -> logger.error("No public key: ${e.message}") + } +} +``` + +--- + +## Sealed Class vs Sealed Interface + +### When to Use Sealed Class + +**Examples from codebase:** + +1. **AccountState** - State variants with different data +2. **VerificationState** - State machine +3. **MuteTag** - Tag variants with common base properties +4. **SignerExceptions** - Exception hierarchy + +**Characteristics:** +- Need common constructor parameters +- Single inheritance only +- State variants +- Exception hierarchies + +### When to Use Sealed Interface + +**Examples from codebase:** + +1. **SignerResult** - Generic result types needing variance +2. **RelayUrlNormalizer.Result** - Binary result with no shared state + +**Characteristics:** +- Need generics with variance (`out`, `in`) +- No common state needed +- Multiple inheritance possible +- Contract/capability representation + +--- + +## Patterns + +### Pattern: State Machine + +```kotlin +sealed class ConnectionState { + data object Disconnected : ConnectionState() + data object Connecting : ConnectionState() + data class Connected(val relay: String) : ConnectionState() + data class Failed(val error: String) : ConnectionState() +} + +// Allowed transitions +fun transition(from: ConnectionState, event: Event): ConnectionState { + return when (from) { + is ConnectionState.Disconnected -> { + when (event) { + is Event.Connect -> ConnectionState.Connecting + else -> from + } + } + is ConnectionState.Connecting -> { + when (event) { + is Event.Success -> ConnectionState.Connected(event.relay) + is Event.Error -> ConnectionState.Failed(event.message) + is Event.Cancel -> ConnectionState.Disconnected + else -> from + } + } + is ConnectionState.Connected -> { + when (event) { + is Event.Disconnect -> ConnectionState.Disconnected + is Event.Error -> ConnectionState.Failed(event.message) + else -> from + } + } + is ConnectionState.Failed -> { + when (event) { + is Event.Retry -> ConnectionState.Connecting + is Event.Cancel -> ConnectionState.Disconnected + else -> from + } + } + } +} +``` + +### Pattern: Result Type + +```kotlin +sealed interface Result { + data class Success(val data: T) : Result + data class Error(val exception: Exception) : Result + data object Loading : Result +} + +// Extension functions +fun Result.getOrNull(): T? = when (this) { + is Result.Success -> data + else -> null +} + +fun Result.getOrThrow(): T = when (this) { + is Result.Success -> data + is Result.Error -> throw exception + is Result.Loading -> error("Still loading") +} + +fun Result.map(transform: (T) -> R): Result = when (this) { + is Result.Success -> Result.Success(transform(data)) + is Result.Error -> this + is Result.Loading -> Result.Loading +} +``` + +### Pattern: Tagged Union (Discriminated Union) + +```kotlin +sealed class Command { + data class SendEvent(val event: Event) : Command() + data class Subscribe(val filters: List) : Command() + data class Unsubscribe(val subId: String) : Command() + data object Close : Command() + + fun toJson(): String = when (this) { + is SendEvent -> """["EVENT",${event.toJson()}]""" + is Subscribe -> """["REQ","sub",${filters.joinToString { it.toJson() }}]""" + is Unsubscribe -> """["CLOSE","$subId"]""" + is Close -> """["CLOSE"]""" + } +} +``` + +### Pattern: Nested Sealed Hierarchies + +```kotlin +sealed interface UiState { + sealed interface Loading : UiState { + data object Initial : Loading + data class Refreshing(val currentData: List) : Loading + } + + sealed interface Content : UiState { + data class Success(val data: List) : Content + data object Empty : Content + } + + sealed interface Error : UiState { + data class Network(val message: String) : Error + data class Server(val code: Int, val message: String) : Error + } +} + +// Usage +fun renderUi(state: UiState) { + when (state) { + is UiState.Loading.Initial -> showFullScreenLoader() + is UiState.Loading.Refreshing -> showRefreshIndicator(state.currentData) + is UiState.Content.Success -> showList(state.data) + is UiState.Content.Empty -> showEmptyState() + is UiState.Error.Network -> showNetworkError(state.message) + is UiState.Error.Server -> showServerError(state.code, state.message) + } +} +``` + +--- + +## All Sealed Types in Quartz + +**Complete list of sealed types found in codebase:** + +### Commons +- AccountState (class) + +### Quartz +- BaseZapSplitSetup (class) +- MuteTag (class) +- BookmarkIdTag (class) +- SignerResult (interface) +- VerificationState (class) +- CacheResults (class) +- SignerExceptions (class) +- RelayUrlNormalizer.Result (interface) + +**Total:** 8 sealed types (7 classes, 1 interface) + +--- + +## Decision Tree + +``` +Need to represent variants of a concept? + YES → Use sealed type + NO → Regular class/interface + +Variants have different data? + YES → sealed class or sealed interface + NO → enum (if simple constants) + +Need generics with variance (out/in)? + YES → sealed interface + NO → sealed class (simpler) + +Need common constructor/properties? + YES → sealed class + NO → sealed interface + +Need multiple inheritance? + YES → sealed interface + NO → Either works + +Representing state machine? + → sealed class (state transitions) + +Representing result/error types? + → sealed interface (if generic, else class) + +Representing tag/command variants? + → sealed class (common structure) +``` + +--- + +## References + +- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html) +- [Effective Kotlin: Sealed Classes](https://kt.academy/article/ek-sealed-classes) +- [Complete Guide: Sealed Classes & Interfaces 2025](https://proandroiddev.com/complete-technical-guide-sealed-classes-sealed-interfaces-enums-in-kotlin-28ffc39116df) diff --git a/.claude/skills/kotlin-multiplatform/SKILL.md b/.claude/skills/kotlin-multiplatform/SKILL.md new file mode 100644 index 000000000..b0aa2f8d6 --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/SKILL.md @@ -0,0 +1,402 @@ +--- +name: kotlin-multiplatform +description: | + Platform abstraction decision-making for Amethyst KMP project. Guides when to abstract vs keep platform-specific, + source set placement (commonMain, jvmAndroid, platform-specific), expect/actual patterns. Covers primary targets + (Android, JVM/Desktop, iOS) with web/wasm future considerations. Integrates with gradle-expert for dependency issues. + Triggers on: abstraction decisions ("should I share this?"), source set placement questions, expect/actual creation, + build.gradle.kts work, incorrect placement detection, KMP dependency suggestions. +--- + +# Kotlin Multiplatform: Platform Abstraction Decisions + +Expert guidance for KMP architecture in Amethyst - deciding what to share vs keep platform-specific. + +## When to Use This Skill + +Making platform abstraction decisions: +- "Should I create expect/actual or keep Android-only?" +- "Can I share this ViewModel logic?" +- "Where does this crypto/JSON/network implementation belong?" +- "This uses Android Context - can it be abstracted?" +- "Is this code in the wrong module?" +- Preparing for iOS/web/wasm targets +- Detecting incorrect placements + +## Abstraction Decision Tree + +**Central question:** "Should this code be reused across platforms?" + +Follow this decision path (< 1 minute): + +``` +Q: Is it used by 2+ platforms? +├─ NO → Keep platform-specific +│ Example: Android-only permission handling +│ +└─ YES → Continue ↓ + +Q: Is it pure Kotlin (no platform APIs)? +├─ YES → commonMain +│ Example: Nostr event parsing, business rules +│ +└─ NO → Continue ↓ + +Q: Does it vary by platform or by JVM vs non-JVM? +├─ By platform (Android ≠ iOS ≠ Desktop) +│ → expect/actual +│ Example: Secp256k1Instance (uses different security APIs) +│ +├─ By JVM (Android = Desktop ≠ iOS/web) +│ → jvmAndroid +│ Example: Jackson JSON parsing (JVM library) +│ +└─ Complex/UI-related + → Keep platform-specific + Example: Navigation (Activity vs Window too different) + +Final check: +Q: Maintenance cost of abstraction < duplication cost? +├─ YES → Proceed with abstraction +└─ NO → Duplicate (simpler) +``` + +### Real Examples from Codebase + +**Crypto → expect/actual:** +```kotlin +// commonMain - expect declaration +expect object Secp256k1Instance { + fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray +} + +// androidMain - uses Android Keystore +// jvmMain - uses Desktop JVM crypto +// iosMain - uses iOS Security framework +``` +**Why:** Each platform has different security APIs. + +**JSON parsing → jvmAndroid:** +```kotlin +// quartz/build.gradle.kts +val jvmAndroid = create("jvmAndroid") { + api(libs.jackson.module.kotlin) +} +``` +**Why:** Jackson is JVM-only, works on Android + Desktop, not iOS/web. + +**Navigation → platform-specific:** +- Android: `MainActivity` (Activity + Compose Navigation) +- Desktop: `Window` + sidebar + MenuBar +**Why:** UI paradigms fundamentally different. + +## Mental Model: Source Sets as Dependency Graph + +Think of source sets as a dependency graph, not folders. + +``` +┌─────────────────────────────────────────────┐ +│ commonMain = Contract (pure Kotlin) │ +│ - Business logic, protocol, data models │ +│ - No platform APIs │ +└────────────┬────────────────────────────────┘ + │ + ├──────────────────────┬──────────────────── + │ │ + ▼ ▼ + ┌───────────────────┐ ┌──────────────────┐ + │ jvmAndroid │ │ iosMain │ + │ JVM libs shared │ │ iOS common │ + │ - Jackson │ │ │ + │ - OkHttp │ └────┬─────────────┘ + └───┬───────────┬───┘ │ + │ │ ├─→ iosX64Main + ▼ ▼ ├─→ iosArm64Main + ┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main + │android │ │jvmMain │ + │Main │ │(Desktop) │ + └─────────┘ └──────────┘ + +Future: jsMain, wasmMain +``` + +**Key insight:** jvmAndroid is NOT a platform - it's a shared JVM layer. + +## The jvmAndroid Pattern + +**Unique to Amethyst.** Shares JVM libraries between Android + Desktop. + +### When to Use jvmAndroid + +Use jvmAndroid when: +- ✅ JVM-specific libraries (Jackson, OkHttp, url-detector) +- ✅ Android implementation = Desktop implementation (same JVM) +- ✅ Library doesn't work on iOS/web + +Do NOT use jvmAndroid for: +- ❌ Pure Kotlin code (use commonMain) +- ❌ Platform-specific APIs (use androidMain/jvmMain) +- ❌ Code that should work on all platforms + +### Example from quartz/build.gradle.kts + +```kotlin +// Must be defined BEFORE androidMain and jvmMain +val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + + dependencies { + api(libs.jackson.module.kotlin) // JSON parsing - JVM only + api(libs.url.detector) // URL extraction - JVM only + implementation(libs.okhttp) // HTTP client - JVM only + } +} + +// Both depend on jvmAndroid +jvmMain { dependsOn(jvmAndroid) } +androidMain { dependsOn(jvmAndroid) } +``` + +**Why Jackson in jvmAndroid, not commonMain?** +- Jackson is JVM-specific library +- Works on Android (runs on JVM) +- Works on Desktop (runs on JVM) +- Does NOT work on iOS (not JVM) or web (not JVM) + +**Web/wasm consideration:** For future web support, consider migrating from Jackson → kotlinx.serialization (see Target-Specific Guidance). + +## What to Abstract vs Keep Platform-Specific + +Quick decision guidelines based on codebase patterns: + +### Always Abstract +- **Crypto** (Secp256k1, encryption, signing) +- **Core protocol logic** (Nostr events, NIPs) +- **Why:** Needed everywhere, platform security APIs vary + +### Often Abstract +- **I/O operations** (file reading, caching) +- **Logging** (platform logging systems differ) +- **Serialization** (if using kotlinx.serialization) +- **Why:** Commonly reused, platform implementations available + +### Sometimes Abstract +- **Business logic:** YES - state machines, data processing +- **ViewModels:** YES - state + business logic shareable (StateFlow/SharedFlow) +- **Screen layouts:** NO - platform-native (Window vs Activity) +- **Why:** ViewModels contain platform-agnostic state; Screens render differently per platform + +### Rarely Abstract +- **Complex UI components** (composables with heavy platform dependencies) +- **Why:** Platform paradigms can differ significantly + +### Never Abstract +- **Navigation** (Activity vs Window fundamentally different) +- **Permissions** (Android vs iOS APIs incompatible) +- **Platform UX patterns** +- **Why:** Too platform-specific, abstraction creates leaky APIs + +### Evidence from shared-ui-analysis.md + +| Component | Shared? | Rationale | +|-----------|---------|-----------| +| PubKeyFormatter, ZapFormatter | ✅ YES | Pure Kotlin, no platform APIs | +| TimeAgoFormatter | ⚠️ ABSTRACTED | Needs StringProvider for localized strings | +| ViewModels (state + logic) | ✅ YES | StateFlow/SharedFlow platform-agnostic, Compose Multiplatform lifecycle compatible | +| Screen layouts (Scaffold, nav) | ❌ NO | Window vs Activity, sidebar vs bottom nav fundamentally different | +| Image loading (Coil) | ⚠️ ABSTRACTED | Coil 3.x supports KMP, needs expect/actual wrapper | + +## expect/actual Mechanics + +**When to use:** Code needed by 2+ platforms, varies by platform. + +### Pattern Categories from Codebase + +**Objects (singletons):** +```kotlin +// 24 expect declarations found, common pattern: +expect object Secp256k1Instance { ... } +expect object Log { ... } +expect object LibSodiumInstance { ... } +``` + +**Classes (instantiable):** +```kotlin +expect class AESCBC { ... } +expect class DigestInstance { ... } +``` + +**Functions (utilities):** +```kotlin +expect fun platform(): String +expect fun currentTimeSeconds(): Long +``` + +**See** [references/expect-actual-catalog.md](references/expect-actual-catalog.md) for complete catalog with rationale. + +## Target-Specific Guidance + +### Android, JVM (Desktop), iOS - Current Primary Targets + +**Status:** Mature patterns, stable APIs + +**Android (androidMain):** +- Uses Android framework (Activity, Context, etc.) +- secp256k1-kmp-jni-android for crypto +- AndroidX libraries + +**Desktop JVM (jvmMain):** +- Uses Compose Desktop (Window, MenuBar, etc.) +- secp256k1-kmp-jni-jvm for crypto +- Pure JVM libraries + +**iOS (iosMain):** +- Active development, framework configured +- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main +- Platform APIs via platform.posix, Security framework + +### Web, wasm - Future Targets + +**Status:** Not yet implemented, consider for future-proofing + +**Constraints to know:** +- ❌ No platform.posix (file I/O different) +- ❌ No JVM libraries (Jackson, OkHttp won't work) +- ❌ Different async model (JS event loop vs threads) + +**Future-proofing tips:** +1. Prefer pure Kotlin in commonMain +2. Use kotlinx.* libraries: + - kotlinx.serialization instead of Jackson + - ktor instead of OkHttp (ktor supports web) + - kotlinx.datetime instead of custom date handling +3. Avoid platform.posix for file operations +4. Test abstractions work without JVM assumptions + +**Example migration path:** +```kotlin +// Current: jvmAndroid (JVM-only) +api(libs.jackson.module.kotlin) + +// Future: commonMain (all platforms) +api(libs.kotlinx.serialization.json) +``` + +## Integration: When to Invoke Other Skills + +### Invoke gradle-expert + +Trigger gradle-expert skill when encountering: +- Dependency conflicts (e.g., secp256k1-android vs secp256k1-jvm version mismatch) +- Build errors related to source sets +- Version catalog issues (libs.versions.toml) +- "Duplicate class" errors +- Performance/build time issues + +**Example trigger:** +``` +Error: Duplicate class found: fr.acinq.secp256k1.Secp256k1 +``` +→ Invoke gradle-expert for dependency conflict resolution. + +### Flags to Raise + +**Platform code in commonMain:** +```kotlin +// ❌ INCORRECT - Android API in commonMain +expect fun getContext(): Context // Context is Android-only! +``` +→ Flag: "Android API in commonMain won't compile on other platforms" + +**Duplicated business logic:** +```kotlin +// ❌ INCORRECT - Same logic in both +// androidMain/.../CryptoUtils.kt +fun validateSignature(...) { ... } + +// jvmMain/.../CryptoUtils.kt +fun validateSignature(...) { ... } // Duplicated! +``` +→ Flag: "Business logic duplicated, should be in commonMain or expect/actual" + +**Reinventing wheel - suggest KMP alternatives:** +- Custom date/time → kotlinx.datetime +- OkHttp → ktor (supports web) +- Jackson → kotlinx.serialization +- Custom UUID → kotlinx.uuid (when stable) + +## Common Pitfalls + +### 1. Over-Abstraction +**Problem:** Creating expect/actual for UI components +```kotlin +// ❌ BAD +expect fun NavigationComponent(...) +``` +**Why:** Navigation paradigms too different (Activity vs Window) +**Fix:** Keep platform-specific, accept duplication + +### 2. Under-Sharing +**Problem:** Duplicating business logic across platforms +```kotlin +// ❌ BAD - duplicated in androidMain and jvmMain +fun parseNostrEvent(json: String): Event { ... } +``` +**Why:** Bug fixes need to be applied twice, tests duplicated +**Fix:** Move to commonMain (pure Kotlin) or create expect/actual + +### 3. Leaky Abstractions +**Problem:** Platform code in commonMain +```kotlin +// commonMain - ❌ BAD +import android.content.Context // Won't compile on iOS! +``` +**Fix:** Use expect/actual or dependency injection + +### 4. Premature Abstraction +**Problem:** Creating expect/actual before second platform needs it +```kotlin +// ❌ BAD - only used on Android currently +expect fun showNotification(...) +``` +**Why:** Wrong abstraction boundaries, wasted effort +**Fix:** Wait until iOS actually needs it, then abstract + +### 5. Wrong Source Set +**Problem:** JVM libraries in commonMain +```kotlin +// commonMain - ❌ BAD +import com.fasterxml.jackson.databind.ObjectMapper +``` +**Why:** Jackson won't compile on iOS/web +**Fix:** Move to jvmAndroid or migrate to kotlinx.serialization + +## Quick Reference + +| Code Type | Recommended Location | Reason | +|-----------|---------------------|--------| +| Pure Kotlin business logic | commonMain | Works everywhere | +| Nostr protocol, NIPs | commonMain | Core logic, no platform APIs | +| JVM libs (Jackson, OkHttp) | jvmAndroid | Android + Desktop only | +| Crypto (varies by platform) | expect in commonMain, actual in platforms | Different security APIs per platform | +| I/O, logging | expect in commonMain, actual in platforms | Platform implementations differ | +| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns | +| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** | +| UI formatters (pure) | commons/commonMain | Reusable, no dependencies | +| UI components (simple) | commons/commonMain | Cards, buttons, dialogs | +| **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** | +| Navigation | Platform-specific only | Activity vs Window too different | +| Permissions | Platform-specific only | APIs incompatible | +| Platform UX (menus, etc.) | Platform-specific only | Native feel required | + +## See Also + +- [references/abstraction-examples.md](references/abstraction-examples.md) - Good/bad abstraction examples with rationale +- [references/source-set-hierarchy.md](references/source-set-hierarchy.md) - Visual hierarchy with Amethyst examples +- [references/expect-actual-catalog.md](references/expect-actual-catalog.md) - All 24 expect/actual pairs with "why abstracted" +- [references/target-compatibility.md](references/target-compatibility.md) - Platform constraints and future-proofing + +## Scripts + +- `scripts/validate-kmp-structure.sh` - Detect incorrect placements, validate source sets +- `scripts/suggest-kmp-dependency.sh` - Suggest KMP library alternatives (ktor, kotlinx.serialization, etc.) diff --git a/.claude/skills/kotlin-multiplatform/references/abstraction-examples.md b/.claude/skills/kotlin-multiplatform/references/abstraction-examples.md new file mode 100644 index 000000000..e1277dc75 --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/references/abstraction-examples.md @@ -0,0 +1,311 @@ +# Abstraction Examples from Amethyst Codebase + +Real examples of abstraction decisions with rationale. + +## Good Abstractions (Why They Work) + +### 1. Secp256k1Instance - Crypto Signing + +**Location:** expect in commonMain, actual in androidMain/jvmMain/iosMain + +**Code:** +```kotlin +// quartz/src/commonMain/.../Secp256k1Instance.kt +expect object Secp256k1Instance { + fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray + fun verifySchnorr(signature: ByteArray, hash: ByteArray, pubKey: ByteArray): Boolean +} +``` + +**Why abstracted:** +- Used by all platforms (Android, Desktop, iOS) +- Security APIs fundamentally different: + - Android: secp256k1-kmp-jni-android (Android Keystore integration) + - Desktop: secp256k1-kmp-jni-jvm (pure JVM crypto) + - iOS: Native Security framework +- Core protocol requirement (Nostr signatures) + +**Decision rationale:** Always abstract crypto - varies by platform security APIs, critical for all platforms. + +--- + +### 2. Log - Platform Logging + +**Location:** expect object in commonMain + +**Code:** +```kotlin +// quartz/src/commonMain/.../Log.kt +expect object Log { + fun d(tag: String, message: String) + fun w(tag: String, message: String, throwable: Throwable?) + fun e(tag: String, message: String, throwable: Throwable?) +} +``` + +**Why abstracted:** +- Used throughout quartz module (protocol library) +- Logging systems differ: + - Android: android.util.Log + - Desktop: println or logging framework + - iOS: NSLog or OSLog +- Simple interface, easy to implement + +**Decision rationale:** Often abstract logging - platform systems differ, widely used, simple interface. + +--- + +### 3. Platform Utils - Time & Platform Name + +**Location:** expect functions in commonMain + +**Code:** +```kotlin +// quartz/src/commonMain/.../Platform.kt +expect fun platform(): String +expect fun currentTimeSeconds(): Long +``` + +**Why abstracted:** +- Used by Nostr event creation (timestamps) +- Platform name for debugging +- Simple utilities, clear platform boundary + +**Decision rationale:** Platform utilities are good abstraction candidates - simple, useful everywhere. + +--- + +### 4. Jackson JSON (jvmAndroid Pattern) + +**Location:** jvmAndroid source set + +**Code:** +```kotlin +// quartz/build.gradle.kts +val jvmAndroid = create("jvmAndroid") { + api(libs.jackson.module.kotlin) // JVM-only library +} +``` + +**Why jvmAndroid (not commonMain):** +- Jackson is JVM-specific library +- Works on Android (JVM) + Desktop (JVM) +- Does NOT work on iOS (not JVM) or web (not JVM) +- Performance-critical JSON parsing + +**Decision rationale:** Use jvmAndroid for JVM libraries shared between Android and Desktop. + +**Future consideration:** For web support, migrate to kotlinx.serialization (works on all platforms). + +--- + +## Bad/Over-Abstractions (Why They Failed) + +### 1. Navigation Abstraction (Avoided) + +**What COULD have been done:** +```kotlin +// ❌ Over-abstraction - DON'T DO THIS +expect interface Navigator { + fun navigate(route: String) + fun popBackStack() +} +``` + +**Why NOT abstracted:** +- Navigation paradigms fundamentally different: + - Android: Activity + Compose Navigation + back stack + - Desktop: Window + screen state + no back stack concept +- Complex APIs don't map well +- Creates leaky abstraction + +**Actual approach:** Keep platform-specific +- Android: `INav` interface + Compose Navigation +- Desktop: Simple screen enum + state + +**Decision rationale:** Never abstract navigation - platforms too different, abstraction would be leaky. + +--- + +### 2. String Resources (Abstraction Planned) + +**Current state:** Platform-specific (over-duplication) + +**Problem:** +```kotlin +// Android uses R.string.* +Text(stringResource(R.string.post_not_found)) + +// Desktop uses hardcoded strings +Text("Post not found") +``` + +**Why NOT yet abstracted:** Waiting for second platform to fully implement UI, then will create StringProvider interface. + +**Planned abstraction:** +```kotlin +// commonMain +interface StringProvider { + fun get(key: String): String +} + +// androidMain +class AndroidStringProvider(context: Context): StringProvider { ... } + +// jvmMain +class DesktopStringProvider: StringProvider { ... } +``` + +**Lesson:** Don't abstract prematurely - wait until second platform needs it, then create proper abstraction. + +--- + +## Platform-Specific Code (Why NOT Abstracted) + +### 1. MainActivity (Android Activity) + +**Location:** amethyst/src/main/.../MainActivity.kt + +**Code:** +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + setContent { + AmethystTheme { + AccountScreen(accountStateViewModel) + } + } + } +} +``` + +**Why platform-specific:** +- AppCompatActivity is Android framework +- Activity lifecycle unique to Android +- enableEdgeToEdge() is Android-specific API +- No equivalent on Desktop (uses Window) + +**Decision rationale:** Android Activity is platform-specific by nature. + +--- + +### 2. Desktop Window & MenuBar + +**Location:** desktopApp/src/jvmMain/.../Main.kt + +**Code:** +```kotlin +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "Amethyst" + ) { + MenuBar { + Menu("File") { + Item("New Note", onClick = { ... }, shortcut = KeyShortcut(Key.N, ctrl = true)) + Item("Quit", onClick = ::exitApplication) + } + } + NavigationRail { ... } // Sidebar navigation + } +} +``` + +**Why platform-specific:** +- Window, MenuBar, NavigationRail are Compose Desktop APIs +- Keyboard shortcuts (Ctrl+N) are desktop paradigm +- Sidebar navigation vs Android bottom nav +- No equivalent on Android + +**Decision rationale:** Desktop UX patterns are platform-specific by nature. + +--- + +### 3. AccountViewModel (Android ViewModel) + +**Location:** amethyst/.../AccountStateViewModel.kt + +**Partially abstracted:** +- Business logic → IAccountState interface (can be shared) +- UI state + lifecycle → AndroidX ViewModel (Android-only) + +**Why not fully abstracted:** +- AndroidX ViewModel lifecycle tied to Android +- Desktop doesn't need ViewModel (simpler state management) +- SavedStateHandle is Android-specific + +**Decision rationale:** Extract business logic to interface, keep UI state platform-specific. + +--- + +## Migration Examples (Android → Shared) + +### Example 1: PubKeyFormatter (Pure Kotlin) + +**Before:** +```kotlin +// amethyst/ui/note/PubKeyFormatter.kt +fun String.toDisplayHexKey(): String { + return "${take(8)}:${takeLast(8)}" +} +``` + +**After:** +```kotlin +// commons/commonMain/formatters/PubKeyFormatter.kt +fun String.toDisplayHexKey(): String { + return "${take(8)}:${takeLast(8)}" +} + +// Both apps use it +import com.vitorpamplona.amethyst.commons.formatters.toDisplayHexKey +``` + +**Why successful:** +- Pure Kotlin, no platform dependencies +- Widely reused +- Simple utility function + +--- + +### Example 2: TimeAgoFormatter (Requires Abstraction) + +**Problem:** +```kotlin +// Uses Android R.string.* +fun timeAgo(timestamp: Long): String { + return context.getString(R.string.x_minutes_ago, minutes) +} +``` + +**Solution:** Abstract string resources +```kotlin +// commonMain +fun timeAgo(timestamp: Long, stringProvider: StringProvider): String { + return stringProvider.get("x_minutes_ago", minutes) +} + +// androidMain +stringProvider = AndroidStringProvider(context) + +// jvmMain +stringProvider = DesktopStringProvider() +``` + +**Why successful:** Clear platform boundary (string resources), useful on both platforms. + +--- + +## Decision Pattern Summary + +| Pattern | Abstract? | Why | +|---------|-----------|-----| +| Pure Kotlin utilities | ✅ YES | No platform dependency, easy | +| Crypto APIs | ✅ YES (expect/actual) | Platform security APIs differ | +| JVM libraries | ⚠️ jvmAndroid | Works on Android+Desktop only | +| UI components (simple) | ✅ YES | Composables work cross-platform | +| UI components (complex) | ❌ NO | Platform dependencies | +| Navigation | ❌ NO | Paradigms too different | +| ViewModels | ⚠️ PARTIAL | Business logic yes, UI state no | +| String resources | ⚠️ PLANNED | Needs abstraction layer | diff --git a/.claude/skills/kotlin-multiplatform/references/expect-actual-catalog.md b/.claude/skills/kotlin-multiplatform/references/expect-actual-catalog.md new file mode 100644 index 000000000..6b6e98c1c --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/references/expect-actual-catalog.md @@ -0,0 +1,163 @@ +# Complete expect/actual Catalog + +All 24 expect declarations in Amethyst quartz module with rationale. + +| # | Name | Type | Purpose | Why Abstracted | Files | +|---|------|------|---------|----------------|-------| +| 1 | AESCBC | class | AES CBC encryption | Platform crypto APIs differ | quartz/.../ciphers/AESCBC.kt | +| 2 | AESGCM | class | AES GCM encryption | Platform crypto APIs differ | quartz/.../ciphers/AESGCM.kt | +| 3 | DigestInstance | class | Hash digests (SHA256) | Platform implementations | quartz/.../diggest/DigestInstance.kt | +| 4 | MacInstance | class | MAC (HMAC) operations | Platform crypto APIs | quartz/.../mac/MacInstance.kt | +| 5 | Sha256 | object | SHA256 hashing | Platform-specific optimizations | quartz/.../sha256/Sha256.kt | +| 6 | LargeCache | object | Large object caching | Platform storage APIs differ | quartz/.../cache/LargeCache.kt | +| 7 | UriParser | object | URI parsing | Platform URL APIs differ | quartz/.../UriParser.kt | +| 8 | UrlEncoder | object | URL encoding | Platform encoding differs | quartz/.../UrlEncoder.kt | +| 9 | Urls | object | URL utilities | Platform URL handling | quartz/.../Urls.kt | +| 10 | Platform | functions | platform(), currentTimeSeconds() | Platform name & time APIs | quartz/.../Platform.kt | +| 11 | Rfc3986 | object | RFC 3986 URL normalization | Used in jvmAndroid | quartz/.../Rfc3986.kt | +| 12 | Secp256k1Instance | object | Bitcoin crypto (secp256k1) | Different libs per platform | quartz/.../Secp256k1Instance.kt | +| 13 | SecureRandom | object | Cryptographically secure random | Platform random APIs differ | quartz/.../SecureRandom.kt | +| 14 | StringExt | functions | String utilities | Platform string handling | quartz/.../StringExt.kt | +| 15 | UnicodeNormalizer | object | Unicode normalization | Platform text APIs | quartz/.../UnicodeNormalizer.kt | +| 16 | GZip | object | GZip compression | Platform compression APIs | quartz/.../GZip.kt | +| 17 | LibSodiumInstance | object | NaCl/libsodium (NIP-44 encryption) | Different libs per platform | quartz/.../LibSodiumInstance.kt | +| 18 | Log | object | Logging | Platform logging systems | quartz/.../Log.kt | +| 19 | BigDecimal | class | Arbitrary precision decimal | Not in Kotlin common stdlib | quartz/.../BigDecimal.kt | +| 20 | BitSet | class | Bit set data structure | Not in Kotlin common stdlib | quartz/.../BitSet.kt | +| 21 | ServerInfoParser | object | Server info parsing (NIP-96) | Platform JSON parsing | quartz/.../nip96.../ServerInfoParser.kt | +| 22 | EventHasherSerializer | object | Event hashing | Platform-specific optimizations | quartz/.../nip01Core.../EventHasherSerializer.kt | +| 23 | OptimizedJsonMapper | object | JSON mapping | Platform JSON libraries | quartz/.../nip01Core.../OptimizedJsonMapper.kt | +| 24 | Address | data class | Address data structure | Platform-specific string handling | quartz/.../nip01Core.../Address.kt | + +## Pattern Analysis + +### Objects (Singletons) - 19 total +Most common pattern for platform-specific singletons: +- Crypto: Secp256k1Instance, LibSodiumInstance, Sha256 +- I/O: UriParser, UrlEncoder, GZip +- Utils: Log, Platform, SecureRandom + +### Classes (Instantiable) - 4 total +For objects that need to maintain state: +- AESCBC, AESGCM (cipher state) +- DigestInstance, MacInstance (hash/MAC state) +- BigDecimal, BitSet (data structures) + +### Functions - 2 total +Simple utilities: +- platform(), currentTimeSeconds() + +## Why Abstracted Categories + +### Crypto (8 items) +**Always abstract:** Security APIs fundamentally different across platforms +- Android: Android Keystore, secp256k1-android +- Desktop: JVM crypto, secp256k1-jvm +- iOS: Security framework, native crypto + +### I/O & Platform Utils (7 items) +**Often abstract:** File systems, URLs, compression differ +- Platform storage APIs +- URL handling varies +- Compression libraries differ + +### Data Structures (2 items) +**Abstract when missing:** Not available in Kotlin common stdlib +- BigDecimal, BitSet not in common + +### JSON/Parsing (3 items) +**Platform-specific optimization:** Uses platform JSON libraries +- Android/Desktop: Jackson (via jvmAndroid) +- iOS: Native parsers + +### Logging (1 item) +**Always abstract:** Platform logging systems differ +- Android: android.util.Log +- Desktop: println or logging framework +- iOS: NSLog or OSLog + +## Actual Implementation Examples + +### Simple Object Pattern + +```kotlin +// commonMain +expect object Log { + fun d(tag: String, message: String) +} + +// androidMain +actual object Log { + actual fun d(tag: String, message: String) { + android.util.Log.d(tag, message) + } +} + +// jvmMain +actual object Log { + actual fun d(tag: String, message: String) { + println("[$tag] $message") + } +} +``` + +### Complex Object with Dependencies + +```kotlin +// commonMain +expect object Secp256k1Instance { + fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray +} + +// androidMain - uses JNI bindings +actual object Secp256k1Instance { + actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray { + return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null) + } +} + +// jvmMain - different JNI library +actual object Secp256k1Instance { + actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray { + return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null) + } +} + +// iosMain - native iOS implementation +actual object Secp256k1Instance { + actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray { + // Uses iOS Security framework or native lib + } +} +``` + +### Class Pattern + +```kotlin +// commonMain +expect class BigDecimal { + constructor(value: String) + fun add(other: BigDecimal): BigDecimal + override fun toString(): String +} + +// jvmAndroid (works on Android + Desktop) +actual typealias BigDecimal = java.math.BigDecimal + +// iosMain +actual class BigDecimal { + private val value: NSDecimalNumber + actual constructor(value: String) { + this.value = NSDecimalNumber(value) + } + // ... implementation +} +``` + +## Decision Patterns + +Ask for each declaration: +1. **Used by 2+ platforms?** → YES (otherwise platform-specific) +2. **Pure Kotlin possible?** → NO (otherwise commonMain) +3. **Varies by platform?** → YES (expect/actual) +4. **JVM-only library?** → NO (otherwise jvmAndroid) diff --git a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md new file mode 100644 index 000000000..ec09e0033 --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md @@ -0,0 +1,332 @@ +# Source Set Hierarchy in Amethyst + +Visual guide to source set organization with concrete examples from the codebase. + +## Hierarchy Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ commonMain │ +│ Pure Kotlin, no platform APIs │ +│ Examples: │ +│ - Nostr event parsing (TextNoteEvent, MetadataEvent) │ +│ - Business logic (data validation, crypto algorithms) │ +│ - Data models (@Immutable data classes) │ +│ Dependencies: kotlin-stdlib, kotlinx-coroutines │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ┌────────────┴────────────┬───────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────────┐ ┌───────────────────┐ ┌──────────────┐ +│ jvmAndroid │ │ iosMain │ │ Future: │ +│ JVM libraries │ │ iOS common │ │ jsMain │ +│ Examples: │ │ Examples: │ │ wasmMain │ +│ - Jackson JSON │ │ - Platform API │ └──────────────┘ +│ - OkHttp HTTP │ │ - Actuals for │ +│ - url-detector │ │ crypto/I/O │ +│ Dependencies: │ │ Dependencies: │ +│ - Jackson │ │ - Platform libs │ +│ - OkHttp │ └───────┬───────────┘ +└────┬─────────┬───┘ │ + │ │ ├─→ iosX64Main (simulator Intel) + │ │ ├─→ iosArm64Main (device ARM64) + │ │ └─→ iosSimulatorArm64Main (Apple Silicon) + ▼ ▼ +┌──────────┐ ┌───────────┐ +│android │ │ jvmMain │ +│Main │ │ (Desktop) │ +│Examples: │ │ Examples: │ +│- Activity│ │- Window │ +│- ViewModel│ │- MenuBar │ +│- Android │ │- Desktop │ +│ APIs │ │ Compose │ +│Deps: │ │ Deps: │ +│- secp256k│ │- secp256k │ +│ 1-android│ │ 1-jvm │ +│- androidx│ │- Compose │ +│ │ │ Desktop │ +└──────────┘ └───────────┘ +``` + +## Dependency Flow + +``` +Code in commonMain + ↓ can use +Nothing (only Kotlin stdlib) + +Code in jvmAndroid + ↓ can use +commonMain + JVM libraries (Jackson, OkHttp) + +Code in androidMain + ↓ can use +commonMain + jvmAndroid + Android framework + +Code in jvmMain + ↓ can use +commonMain + jvmAndroid + JVM + Compose Desktop + +Code in iosMain + ↓ can use +commonMain + iOS platform APIs +``` + +## Real Examples from Amethyst + +### commonMain - Pure Kotlin + +**File:** `quartz/src/commonMain/.../TextNoteEvent.kt` + +```kotlin +@Immutable +class TextNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseThreadedEvent(...) { + // Pure Kotlin - works everywhere + override fun indexableContent() = "Subject: " + subject() + "\n" + content +} +``` + +**Why commonMain:** +- Pure Kotlin code +- No platform APIs +- Data class with business logic +- Needed by all platforms + +--- + +### jvmAndroid - JVM Libraries + +**File:** `quartz/build.gradle.kts` + +```kotlin +val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + + dependencies { + // Normalizes URLs + api(libs.rfc3986.normalizer) + + // Performant Parser of JSONs into Events + api(libs.jackson.module.kotlin) + + // Parses URLs from Text + api(libs.url.detector) + + // Websockets API + implementation(libs.okhttp) + implementation(libs.okhttpCoroutines) + } +} + +jvmMain { dependsOn(jvmAndroid) } // Desktop gets Jackson, OkHttp +androidMain { dependsOn(jvmAndroid) } // Android gets Jackson, OkHttp +``` + +**Why jvmAndroid:** +- Jackson, OkHttp are JVM-only libraries +- Works on Android (JVM) and Desktop (JVM) +- Does NOT work on iOS (not JVM) or web (not JVM) + +**Usage in code:** +```kotlin +// Can use Jackson in jvmAndroid source set +val mapper = ObjectMapper() +val event = mapper.readValue(json, Event::class.java) +``` + +--- + +### androidMain - Android Platform + +**File:** `amethyst/src/main/.../MainActivity.kt` + +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() // Android API + super.onCreate(savedInstanceState) + + setContent { // Compose for Android + AmethystTheme { + val accountStateViewModel: AccountStateViewModel = viewModel() + AccountScreen(accountStateViewModel) + } + } + } +} +``` + +**Why androidMain:** +- AppCompatActivity is Android framework +- Activity lifecycle Android-specific +- AndroidX libraries (viewModel()) + +**Dependencies:** +```kotlin +androidMain { + dependsOn(jvmAndroid) // Gets Jackson, OkHttp + dependencies { + implementation(libs.androidx.core.ktx) + api(libs.secp256k1.kmp.jni.android) // Android crypto + } +} +``` + +--- + +### jvmMain - Desktop Platform + +**File:** `desktopApp/src/jvmMain/.../Main.kt` + +```kotlin +fun main() = application { + val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp + ) + + Window( // Compose Desktop API + onCloseRequest = ::exitApplication, + state = windowState, + title = "Amethyst" + ) { + MenuBar { // Desktop-specific + Menu("File") { + Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) + } + } + NavigationRail { ... } // Sidebar + } +} +``` + +**Why jvmMain:** +- Window, MenuBar, NavigationRail are Compose Desktop +- Keyboard shortcuts desktop paradigm +- Different UX from Android (sidebar vs bottom nav) + +**Dependencies:** +```kotlin +jvmMain { + dependsOn(jvmAndroid) // Gets Jackson, OkHttp + dependencies { + implementation(libs.secp256k1.kmp.jni.jvm) // Desktop crypto + implementation(compose.desktop.currentOs) + } +} +``` + +--- + +### iosMain - iOS Platform + +**File:** `quartz/build.gradle.kts` + +```kotlin +iosMain { + dependsOn(commonMain.get()) + dependencies { + // iOS platform dependencies + } +} + +val iosX64Main by getting { dependsOn(iosMain.get()) } +val iosArm64Main by getting { dependsOn(iosMain.get()) } +val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) } +``` + +**Why iosMain:** +- iOS platform APIs +- Native crypto (Security framework) +- Different from Android/Desktop + +**Architecture targets:** +- iosX64Main: Intel simulator +- iosArm64Main: Device (iPhone, iPad) +- iosSimulatorArm64Main: Apple Silicon simulator + +--- + +## Build Order Matters + +**CRITICAL:** jvmAndroid must be defined BEFORE androidMain and jvmMain: + +```kotlin +// ✅ CORRECT ORDER +val jvmAndroid = create("jvmAndroid") { ... } +jvmMain { dependsOn(jvmAndroid) } +androidMain { dependsOn(jvmAndroid) } + +// ❌ WRONG - Build error +androidMain { dependsOn(jvmAndroid) } // jvmAndroid not defined yet! +val jvmAndroid = create("jvmAndroid") { ... } +``` + +See comment in quartz/build.gradle.kts:131: +```kotlin +// Must be defined before androidMain and jvmMain +val jvmAndroid = create("jvmAndroid") { ... } +``` + +## Choosing the Right Source Set + +Decision flowchart: + +``` +Q: Where should this code go? + +├─ Pure Kotlin? (no platform APIs) +│ └─ commonMain +│ +├─ JVM library? (Jackson, OkHttp) +│ └─ jvmAndroid +│ +├─ Android API? (Activity, Context) +│ └─ androidMain +│ +├─ Desktop API? (Window, MenuBar) +│ └─ jvmMain +│ +└─ iOS API? (platform.posix, Security) + └─ iosMain +``` + +## Future: Web/wasm Source Sets + +**Not yet implemented**, but structure would be: + +``` +commonMain + ├─→ jsMain (JavaScript/Web) + │ └─ JS-specific: DOM APIs, fetch + │ + └─→ wasmMain (WebAssembly) + └─ wasm-specific: limited APIs +``` + +**Constraints:** +- Cannot use jvmAndroid (Jackson, OkHttp) +- Cannot use platform.posix +- Must use pure Kotlin or web-compatible libs (ktor, kotlinx.serialization) + +## Summary Table + +| Source Set | Extends | Can Use | Example Code | +|------------|---------|---------|--------------| +| commonMain | - | Kotlin stdlib only | TextNoteEvent, business logic | +| jvmAndroid | commonMain | JVM libs (Jackson, OkHttp) | JSON parsing, HTTP | +| androidMain | jvmAndroid | Android framework | Activity, ViewModel | +| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar | +| iosMain | commonMain | iOS platform | Security framework | +| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific | +| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific | +| jsMain | commonMain | JS/DOM | Web (future) | +| wasmMain | commonMain | wasm APIs | WebAssembly (future) | diff --git a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md new file mode 100644 index 000000000..7ccf720fb --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md @@ -0,0 +1,345 @@ +# Target Compatibility Guide + +Current targets (Android, JVM/Desktop, iOS) and future targets (web, wasm) with constraints. + +## Current Primary Targets + +### Android (androidMain) + +**Status:** ✅ Mature, production-ready + +**Runtime:** JVM (Dalvik/ART) + +**Available:** +- Android framework (Activity, Context, Intent, etc.) +- AndroidX libraries (ViewModel, Navigation, etc.) +- JVM libraries via jvmAndroid (Jackson, OkHttp) +- Platform-specific crypto: secp256k1-kmp-jni-android + +**Constraints:** +- Mobile UX paradigms (bottom navigation, vertical scroll) +- Touch-first interaction +- Limited screen space +- Battery/performance constraints + +**Example code:** +```kotlin +// androidMain +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Android-specific lifecycle + } +} +``` + +--- + +### JVM / Desktop (jvmMain) + +**Status:** ✅ Active development, functional + +**Runtime:** JVM + +**Available:** +- Pure JVM libraries +- JVM libraries via jvmAndroid (Jackson, OkHttp) +- Compose Desktop (Window, MenuBar, etc.) +- Platform-specific crypto: secp256k1-kmp-jni-jvm + +**Constraints:** +- Desktop UX paradigms (sidebar, menus, keyboard shortcuts) +- Keyboard + mouse interaction +- Larger screen space +- Different navigation patterns (no back stack) + +**Example code:** +```kotlin +// jvmMain +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "Amethyst" + ) { + MenuBar { ... } // Desktop-specific + NavigationRail { ... } // Sidebar + } +} +``` + +--- + +### iOS (iosMain + architecture targets) + +**Status:** ⚠️ In development, framework configured + +**Runtime:** Native iOS + +**Source sets:** +- iosMain (common iOS code) +- iosX64Main (Intel simulator) +- iosArm64Main (device - iPhone/iPad) +- iosSimulatorArm64Main (Apple Silicon simulator) + +**Available:** +- iOS platform APIs (platform.posix, Foundation, etc.) +- Native crypto (Security framework) +- SwiftUI integration (via KMP framework) + +**NOT available:** +- JVM libraries (Jackson, OkHttp) +- jvmAndroid source set +- JVM-specific APIs + +**Constraints:** +- Mobile UX (similar to Android) +- Swift/Objective-C interop +- XCFramework distribution +- CocoaPods integration + +**Example code:** +```kotlin +// iosMain +actual object Secp256k1Instance { + actual fun signSchnorr(...): ByteArray { + // Use iOS Security framework + } +} +``` + +**XCFramework setup:** +```kotlin +// quartz/build.gradle.kts +kotlin { + listOf(iosX64(), iosArm64(), iosSimulatorArm64()) + .forEach { target -> + target.binaries.framework { + baseName = "quartz-kmpKit" + isStatic = true + } + } +} +``` + +--- + +## Future Targets + +### Web / JavaScript (jsMain) + +**Status:** ❌ Not implemented, consider for future + +**Runtime:** JavaScript (browser or Node.js) + +**Available:** +- Kotlin/JS stdlib +- JS/DOM APIs +- kotlinx.* libraries (serialization, coroutines, datetime) +- ktor-client (HTTP) + +**NOT available:** +- ❌ JVM libraries (Jackson, OkHttp) +- ❌ jvmAndroid source set +- ❌ platform.posix (no file system access like native) +- ❌ Blocking APIs (different async model - JS event loop) + +**Constraints:** +- Single-threaded event loop +- No blocking calls +- Different async patterns (Promises, async/await) +- Browser security (CORS, no file system) + +**Migration path from current code:** + +| Current (jvmAndroid) | Web-compatible alternative | +|---------------------|---------------------------| +| Jackson JSON | kotlinx.serialization | +| OkHttp HTTP | ktor-client | +| java.math.BigDecimal | Kotlin BigDecimal (coming) | +| Blocking I/O | Suspending functions | + +**Example migration:** +```kotlin +// Current: jvmAndroid +val mapper = ObjectMapper() +val event = mapper.readValue(json, Event::class.java) + +// Future: commonMain (works on web) +val json = Json { ignoreUnknownKeys = true } +val event = json.decodeFromString(jsonString) +``` + +--- + +### WebAssembly (wasmMain) + +**Status:** ❌ Not implemented, experimental Kotlin/Wasm + +**Runtime:** WebAssembly + +**Available:** +- Kotlin/Wasm stdlib +- Limited kotlinx.* libraries +- wasm-specific APIs + +**NOT available:** +- ❌ JVM libraries +- ❌ Full platform.posix +- ❌ Many kotlinx libraries (limited wasm support) + +**Constraints:** +- Even more limited than JS +- Experimental Kotlin support +- Limited library ecosystem + +**Recommendation:** Focus on web (jsMain) first, wasm later. + +--- + +## Cross-Target Compatibility Matrix + +| Feature | Android | JVM/Desktop | iOS | Web (JS) | wasm | +|---------|---------|-------------|-----|----------|------| +| Pure Kotlin | ✅ | ✅ | ✅ | ✅ | ✅ | +| kotlinx.coroutines | ✅ | ✅ | ✅ | ✅ | ⚠️ | +| kotlinx.serialization | ✅ | ✅ | ✅ | ✅ | ⚠️ | +| kotlinx.datetime | ✅ | ✅ | ✅ | ✅ | ⚠️ | +| ktor-client | ✅ | ✅ | ✅ | ✅ | ❌ | +| Jackson JSON | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ | +| OkHttp | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ | +| platform.posix | ❌ | ❌ | ✅ | ❌ | ⚠️ | +| Compose Multiplatform | ✅ | ✅ | ⚠️ (experimental) | ⚠️ (experimental) | ❌ | + +Legend: +- ✅ Full support +- ⚠️ Limited/experimental +- ❌ Not available + +--- + +## Future-Proofing Recommendations + +### For Web Compatibility + +**DO:** +- ✅ Use kotlinx.serialization instead of Jackson +- ✅ Use ktor-client instead of OkHttp +- ✅ Use kotlinx.datetime instead of java.time +- ✅ Use suspending functions (non-blocking) +- ✅ Keep business logic in commonMain + +**DON'T:** +- ❌ Put JVM libraries in commonMain +- ❌ Use platform.posix for critical features +- ❌ Use blocking I/O +- ❌ Depend on threading (use coroutines) + +**Example:** +```kotlin +// ❌ NOT web-compatible +// jvmAndroid +fun parseJson(json: String): Event { + val mapper = ObjectMapper() // Jackson - JVM only + return mapper.readValue(json, Event::class.java) +} + +// ✅ Web-compatible +// commonMain +@Serializable +data class Event(...) + +fun parseJson(json: String): Event { + return Json.decodeFromString(json) // Works everywhere +} +``` + +### Current Migration Priorities + +**High priority:** (Needed for web) +1. Migrate Jackson → kotlinx.serialization +2. Migrate OkHttp → ktor-client +3. Move business logic to commonMain + +**Medium priority:** (Nice to have) +1. Abstract date/time handling → kotlinx.datetime +2. Remove platform.posix usage where possible +3. Use suspending functions over blocking + +**Low priority:** (Future optimization) +1. wasm-specific optimizations +2. Platform-specific performance tuning + +--- + +## Platform-Specific Patterns + +### Android vs iOS Differences + +| Aspect | Android | iOS | +|--------|---------|-----| +| **Activity/ViewController** | Activity | UIViewController | +| **Navigation** | Compose Navigation | UINavigationController | +| **Lifecycle** | onCreate, onResume, etc. | viewDidLoad, viewWillAppear | +| **Permissions** | Runtime permissions | Info.plist + runtime | +| **Crypto** | secp256k1-android | Security framework | +| **Storage** | Room, SharedPreferences | Core Data, UserDefaults | + +### Desktop vs Mobile Differences + +| Aspect | Desktop | Mobile | +|--------|---------|--------| +| **Navigation** | Sidebar | Bottom nav | +| **Input** | Keyboard + mouse | Touch | +| **Screen** | Large, landscape | Small, portrait | +| **Windows** | Multi-window | Single app | +| **Shortcuts** | Keyboard shortcuts (Ctrl+N) | None | +| **Menus** | MenuBar | Bottom sheets | + +--- + +## Testing Strategy + +### Per-Target Testing + +**Android:** +- Unit tests: androidTest +- Instrumented: androidInstrumentedTest +- Device/emulator testing + +**Desktop:** +- Unit tests: jvmTest +- Manual desktop app testing + +**iOS:** +- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.) +- Simulator/device testing + +**Web (future):** +- Unit tests: jsTest +- Browser testing (Selenium, Playwright) + +### Shared Testing + +**commonTest:** +- Business logic tests +- Pure Kotlin code +- Works on all platforms + +```kotlin +// commonTest +class EventParsingTest { + @Test + fun parseTextNoteEvent() { + // Tests run on all platforms + } +} +``` + +--- + +## Summary + +**Current Focus:** Android, JVM/Desktop, iOS (active development) + +**Future Considerations:** Web (requires migration from Jackson/OkHttp) + +**Key Decision:** Prefer kotlinx.* libraries over JVM-specific libs for future web compatibility. diff --git a/.claude/skills/kotlin-multiplatform/scripts/suggest-kmp-dependency.sh b/.claude/skills/kotlin-multiplatform/scripts/suggest-kmp-dependency.sh new file mode 100755 index 000000000..5b07cd810 --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/scripts/suggest-kmp-dependency.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# Suggests KMP library alternatives for JVM-specific dependencies + +set -e + +PROJECT_ROOT="${1:-.}" +cd "$PROJECT_ROOT" + +echo "=== KMP Dependency Suggestions ===" +echo + +# Colors +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' + +SUGGESTIONS_FOUND=0 + +# Check for Jackson (suggest kotlinx.serialization) +echo "📦 Checking for Jackson JSON..." +if grep -r "jackson" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then + echo -e "${YELLOW}⚠ Found Jackson dependency${NC}" + echo " Current: Jackson (JVM-only)" + echo -e " ${GREEN}Suggest: kotlinx.serialization${NC} (works on all platforms)" + echo + echo " Migration:" + echo " // Remove:" + echo " api(libs.jackson.module.kotlin)" + echo + echo " // Add to commonMain:" + echo " implementation(libs.kotlinx.serialization.json)" + echo + echo " // Code change:" + echo " // Before (Jackson):" + echo " val mapper = ObjectMapper()" + echo " val event = mapper.readValue(json, Event::class.java)" + echo + echo " // After (kotlinx.serialization):" + echo " @Serializable" + echo " data class Event(...)" + echo " val event = Json.decodeFromString(json)" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using Jackson (or already using kotlinx.serialization)${NC}" +fi + +# Check for OkHttp (suggest ktor) +echo +echo "📦 Checking for OkHttp..." +if grep -r "okhttp" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then + echo -e "${YELLOW}⚠ Found OkHttp dependency${NC}" + echo " Current: OkHttp (JVM-only)" + echo -e " ${GREEN}Suggest: ktor-client${NC} (works on all platforms)" + echo + echo " Migration:" + echo " // Remove:" + echo " implementation(libs.okhttp)" + echo + echo " // Add to commonMain:" + echo " implementation(libs.ktor.client.core)" + echo " // Platform-specific engines:" + echo " // androidMain: implementation(libs.ktor.client.android)" + echo " // jvmMain: implementation(libs.ktor.client.cio)" + echo " // iosMain: implementation(libs.ktor.client.darwin)" + echo + echo " // Code change:" + echo " // Before (OkHttp):" + echo " val client = OkHttpClient()" + echo " val request = Request.Builder().url(url).build()" + echo " val response = client.newCall(request).execute()" + echo + echo " // After (ktor):" + echo " val client = HttpClient()" + echo " val response: String = client.get(url)" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using OkHttp (or already using ktor)${NC}" +fi + +# Check for java.time (suggest kotlinx.datetime) +echo +echo "📦 Checking for java.time usage..." +if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.time\." >/dev/null 2>&1; then + echo -e "${YELLOW}⚠ Found java.time imports${NC}" + echo " Current: java.time (JVM-only)" + echo -e " ${GREEN}Suggest: kotlinx.datetime${NC} (works on all platforms)" + echo + echo " Migration:" + echo " // Add to commonMain:" + echo " implementation(libs.kotlinx.datetime)" + echo + echo " // Code change:" + echo " // Before (java.time):" + echo " import java.time.Instant" + echo " val now = Instant.now()" + echo + echo " // After (kotlinx.datetime):" + echo " import kotlinx.datetime.Clock" + echo " val now = Clock.System.now()" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using java.time (or already using kotlinx.datetime)${NC}" +fi + +# Check for java.math.BigDecimal +echo +echo "📦 Checking for java.math.BigDecimal usage..." +if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.math.BigDecimal" >/dev/null 2>&1; then + echo -e "${YELLOW}⚠ Found java.math.BigDecimal imports${NC}" + echo " Current: java.math.BigDecimal (JVM-only)" + echo -e " ${BLUE}Note:${NC} KMP BigDecimal not yet in stable kotlinx" + echo + echo " Options:" + echo " 1. Use expect/actual (current approach in quartz)" + echo " 2. Wait for kotlinx.decimal (proposal stage)" + echo " 3. Use third-party KMP library (e.g., bignum)" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using java.math.BigDecimal directly${NC}" +fi + +# Check for platform.posix usage +echo +echo "📦 Checking for platform.posix usage..." +if find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "import platform.posix\." >/dev/null 2>&1; then + echo -e "${YELLOW}⚠ Found platform.posix in commonMain${NC}" + echo " Current: platform.posix (native platforms only, not web)" + echo -e " ${GREEN}Suggest:${NC} Abstract file I/O with expect/actual" + echo + echo " For web compatibility:" + echo " - iOS/Native: platform.posix" + echo " - Web: Use kotlinx-io or ktor file APIs" + echo " - Create expect/actual for file operations" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using platform.posix in commonMain${NC}" +fi + +# Summary +echo +echo "=== Summary ===" +if [ "$SUGGESTIONS_FOUND" -eq 0 ]; then + echo -e "${GREEN}✓ No JVM-specific dependencies found!${NC}" + echo " Your code is ready for web/wasm targets." +else + echo -e "${YELLOW}Found $SUGGESTIONS_FOUND suggestion(s) for KMP alternatives${NC}" + echo + echo "Priority recommendations:" + echo " 1. ${GREEN}High:${NC} Jackson → kotlinx.serialization (enables web support)" + echo " 2. ${GREEN}High:${NC} OkHttp → ktor-client (enables web support)" + echo " 3. ${GREEN}Medium:${NC} java.time → kotlinx.datetime" + echo " 4. ${GREEN}Low:${NC} Consider web compatibility for platform.posix usage" + echo + echo "Resources:" + echo " - kotlinx.serialization: https://github.com/Kotlin/kotlinx.serialization" + echo " - ktor: https://ktor.io/docs/client.html" + echo " - kotlinx.datetime: https://github.com/Kotlin/kotlinx-datetime" +fi + +exit 0 diff --git a/.claude/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh b/.claude/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh new file mode 100755 index 000000000..d8713bcd0 --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# Validates KMP source set structure and detects common issues + +set -e + +PROJECT_ROOT="${1:-.}" +cd "$PROJECT_ROOT" + +echo "=== Validating KMP Structure ===" +echo + +# Colors for output +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +ISSUES_FOUND=0 + +# Check 1: jvmAndroid defined before androidMain/jvmMain +echo "📋 Checking source set definition order..." +if [ -f "quartz/build.gradle.kts" ]; then + jvmandroid_line=$(grep -n "val jvmAndroid = create" quartz/build.gradle.kts | cut -d: -f1) + android_line=$(grep -n "androidMain {" quartz/build.gradle.kts | cut -d: -f1) + jvm_line=$(grep -n "jvmMain {" quartz/build.gradle.kts | cut -d: -f1) + + if [ -n "$jvmandroid_line" ] && [ -n "$android_line" ] && [ -n "$jvm_line" ]; then + if [ "$jvmandroid_line" -lt "$android_line" ] && [ "$jvmandroid_line" -lt "$jvm_line" ]; then + echo -e "${GREEN}✓${NC} jvmAndroid defined before androidMain and jvmMain" + else + echo -e "${RED}✗${NC} jvmAndroid must be defined BEFORE androidMain and jvmMain" + ISSUES_FOUND=$((ISSUES_FOUND + 1)) + fi + fi +fi + +# Check 2: Platform code in commonMain (Android imports) +echo +echo "📋 Checking for platform code in commonMain..." +android_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import android\." || true) +if [ -n "$android_imports_in_common" ]; then + echo -e "${RED}✗${NC} Found Android imports in commonMain:" + echo "$android_imports_in_common" | sed 's/^/ /' + echo " Fix: Move to androidMain or create expect/actual" + ISSUES_FOUND=$((ISSUES_FOUND + 1)) +else + echo -e "${GREEN}✓${NC} No Android imports in commonMain" +fi + +# Check 3: JVM libraries in commonMain (Jackson, OkHttp) +echo +echo "📋 Checking for JVM libraries in commonMain..." +jvm_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import com.fasterxml.jackson\|^import okhttp3\." || true) +if [ -n "$jvm_imports_in_common" ]; then + echo -e "${RED}✗${NC} Found JVM library imports in commonMain:" + echo "$jvm_imports_in_common" | sed 's/^/ /' + echo " Fix: Move to jvmAndroid or migrate to kotlinx.serialization/ktor" + ISSUES_FOUND=$((ISSUES_FOUND + 1)) +else + echo -e "${GREEN}✓${NC} No JVM library imports in commonMain" +fi + +# Check 4: Unmatched expect/actual declarations +echo +echo "📋 Checking expect/actual pairs..." +expect_files=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^expect " || true) +if [ -n "$expect_files" ]; then + for file in $expect_files; do + # Extract declarations + expects=$(grep "^expect \(class\|object\|fun\|interface\)" "$file" | sed 's/expect //' | awk '{print $2}' | sed 's/[({].*$//') + + # Check for actuals in platform source sets + for expect_name in $expects; do + actual_count=0 + for platform in androidMain jvmMain iosMain; do + platform_dir=$(dirname "$file" | sed "s/commonMain/$platform/") + platform_file="${platform_dir}/$(basename "$file")" + if [ -f "$platform_file" ] && grep -q "actual.*$expect_name" "$platform_file"; then + actual_count=$((actual_count + 1)) + fi + done + + if [ "$actual_count" -eq 0 ]; then + echo -e "${YELLOW}⚠${NC} No actual implementations found for: $expect_name in $file" + echo " Check: androidMain, jvmMain, iosMain" + ISSUES_FOUND=$((ISSUES_FOUND + 1)) + fi + done + done +else + echo -e "${GREEN}✓${NC} No expect declarations to validate" +fi + +# Check 5: Duplicated business logic across platforms +echo +echo "📋 Checking for potential code duplication..." +# This is a heuristic check - look for similar function names in different platform source sets +common_functions=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -h "^fun " | awk '{print $2}' | sed 's/[({<].*$//' | sort -u || true) +if [ -n "$common_functions" ]; then + for func in $common_functions; do + android_count=$(find */src/androidMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l) + jvm_count=$(find */src/jvmMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l) + + if [ "$android_count" -gt 0 ] && [ "$jvm_count" -gt 0 ]; then + echo -e "${YELLOW}⚠${NC} Function '$func' found in both androidMain and jvmMain" + echo " Consider: Move to commonMain or jvmAndroid if truly shared" + fi + done +fi + +# Summary +echo +echo "=== Summary ===" +if [ "$ISSUES_FOUND" -eq 0 ]; then + echo -e "${GREEN}✓ All checks passed!${NC}" + exit 0 +else + echo -e "${RED}✗ Found $ISSUES_FOUND issue(s)${NC}" + echo + echo "Common fixes:" + echo " 1. Platform code in commonMain → Move to androidMain or create expect/actual" + echo " 2. JVM libraries in commonMain → Move to jvmAndroid or migrate to kotlinx.*" + echo " 3. Missing actual implementations → Implement in all target platforms" + echo " 4. Duplicated logic → Move to commonMain or jvmAndroid" + exit 1 +fi diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md new file mode 100644 index 000000000..692f2fe76 --- /dev/null +++ b/.claude/skills/nostr-expert/SKILL.md @@ -0,0 +1,551 @@ +--- +name: nostr-expert +description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. +--- + +# Nostr Protocol Expert (Quartz Implementation) + +Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP Nostr library. + +## When to Use This Skill + +- Implementing Nostr event types (TextNote, Reaction, Zap, etc.) +- Creating/parsing events with TagArrayBuilder DSL +- Working with event kinds and tags +- Finding NIP implementations in quartz/ codebase +- Nostr cryptography (secp256k1 signing, NIP-44 encryption) +- Bech32 encoding/decoding (npub, nsec, note formats) +- Event validation and verification + +**For NIP specifications** → Use `nostr-protocol` agent +**For Quartz implementation** → Use this skill + +## Quartz Architecture + +Quartz organizes code by NIP number: + +``` +quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/ +├── nip01Core/ # Core protocol (Event, Kind, Tags) +├── nip04Dm/ # Legacy DMs (deprecated) +├── nip10Notes/ # Text notes with threading +├── nip17Dm/ # Private DMs (gift wrap) +├── nip19Bech32/ # Bech32 encoding +├── nip44Encryption/ # Modern encryption (ChaCha20) +├── nip57Zaps/ # Lightning zaps +├── ... (57 NIPs total) +└── experimental/ # Draft NIPs +``` + +**Pattern**: `nip##/` directories contain event classes, tags, and utilities for that NIP. + +**Find implementations**: Use `scripts/nip-lookup.sh ` or see `references/nip-catalog.md`. + +## Event Anatomy + +### Core Structure + +```kotlin +@Immutable +open class Event( + val id: HexKey, // SHA-256 hash of serialized event + val pubKey: HexKey, // Author's public key (32 bytes hex) + val createdAt: Long, // Unix timestamp + val kind: Kind, // Event kind (Int typealias) + val tags: TagArray, // Array of tag arrays + val content: String, // Event content + val sig: HexKey, // Schnorr signature (64 bytes hex) +) : IEvent +``` + +**Key insight**: `Event` is the base class. Specific event types (TextNoteEvent, ReactionEvent) extend it and add parsing/helper methods. + +### Kind Classification + +```kotlin +typealias Kind = Int + +fun Kind.isEphemeral() = this in 20000..29999 // Not stored +fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999 +fun Kind.isAddressable() = this in 30000..39999 // Replaceable + has d-tag +fun Kind.isRegular() = this in 1000..9999 // Stored, not replaced +``` + +**Pattern**: Kind determines event lifecycle and replaceability. + +## Creating Events + +### EventTemplate Pattern + +```kotlin +fun eventTemplate( + kind: Kind, + content: String, + tags: TagArray = emptyArray() +): EventTemplate +``` + +**Usage**: +```kotlin +val template = eventTemplate( + kind = 1, // Text note + content = "Hello Nostr!", + tags = tagArray { + add(arrayOf("subject", "Greeting")) + } +) + +// Sign with a signer +val signedEvent = signer.sign(template) +``` + +**Why templates?** Separates event data from signing. Templates can be signed by different signers (local keys, remote signers, hardware wallets). + +### TagArrayBuilder DSL + +```kotlin +fun tagArray( + initializer: TagArrayBuilder.() -> Unit +): TagArray +``` + +**Methods**: +- `add(tag)` - Append tag +- `addFirst(tag)` - Prepend tag (for ordering) +- `addUnique(tag)` - Replace all tags with this name +- `remove(tagName)` - Remove by name +- `addAll(tags)` - Bulk add + +**Example**: +```kotlin +val tags = tagArray { + add(arrayOf("e", replyToEventId, "", "reply")) + add(arrayOf("p", authorPubkey)) + addUnique(arrayOf("subject", "Re: Hello")) + add(arrayOf("content-warning", "spoilers")) +} +``` + +**Pattern**: Fluent DSL for building tag arrays with validation and deduplication. + +## Common Event Types + +### TextNoteEvent (kind 1) + +```kotlin +class TextNoteEvent : BaseThreadedEvent +``` + +**Creating**: +```kotlin +val note = eventTemplate( + kind = 1, + content = "Hello world!", + tags = tagArray { + add(arrayOf("subject", "First post")) + } +) +``` + +**Parsing**: +```kotlin +val event: TextNoteEvent = ... +val subject = event.subject() // Extension from nip14Subject +val mentions = event.mentions() // List of p-tags +val quotedEvents = event.quotes() // List of q-tags +``` + +### ReactionEvent (kind 7) + +```kotlin +fun createReaction( + targetEvent: Event, + emoji: String = "+" +): EventTemplate { + return eventTemplate( + kind = 7, + content = emoji, + tags = tagArray { + add(arrayOf("e", targetEvent.id)) + add(arrayOf("p", targetEvent.pubKey)) + } + ) +} +``` + +### MetadataEvent (kind 0) + +```kotlin +data class UserMetadata( + val name: String?, + val displayName: String?, + val picture: String?, + val banner: String?, + val about: String?, + // ... more fields +) + +fun createMetadata(metadata: UserMetadata): EventTemplate { + return eventTemplate( + kind = 0, + content = metadata.toJson() // Serialize to JSON + ) +} +``` + +### Addressable Events (kinds 30000-40000) + +```kotlin +fun createArticle( + slug: String, + title: String, + content: String +): EventTemplate { + return eventTemplate( + kind = 30023, + content = content, + tags = tagArray { + addUnique(arrayOf("d", slug)) // Unique identifier + add(arrayOf("title", title)) + add(arrayOf("published_at", "${TimeUtils.now()}")) + } + ) +} +``` + +**Key**: `d-tag` makes it addressable. Events with same kind + pubkey + d-tag replace each other. + +## Tag Patterns + +Tags are `Array` with pattern `[name, value, ...optionalParams]`. + +### Core Tags + +**e-tag** (event reference): +```kotlin +add(arrayOf("e", eventId, relayHint, marker)) +// marker: "reply", "root", "mention" +``` + +**p-tag** (pubkey reference): +```kotlin +add(arrayOf("p", pubkey, relayHint)) +``` + +**a-tag** (addressable event): +```kotlin +add(arrayOf("a", "$kind:$pubkey:$dtag", relayHint)) +``` + +**d-tag** (identifier for addressable events): +```kotlin +addUnique(arrayOf("d", "unique-slug")) +``` + +### Tag Extensions + +```kotlin +// Find tags +event.tags.tagValue("subject") // First subject tag value +event.tags.allTags("p") // All p-tags +event.tags.tagValues("e") // All e-tag values + +// Parse structured tags +event.tags.mapNotNull(ETag::parse) // Parse as ETag objects +``` + +For comprehensive tag patterns, see `references/tag-patterns.md`. + +## Threading (NIP-10) + +```kotlin +fun createReply( + original: TextNoteEvent, + content: String +): EventTemplate { + return eventTemplate( + kind = 1, + content = content, + tags = tagArray { + // Reply marker + add(arrayOf("e", original.id, "", "reply")) + + // Root marker (original's root, or original itself) + original.rootEvent()?.let { + add(arrayOf("e", it.id, "", "root")) + } ?: add(arrayOf("e", original.id, "", "root")) + + // Tag author + add(arrayOf("p", original.pubKey)) + + // Tag all mentioned users + original.mentions().forEach { + add(arrayOf("p", it)) + } + } + ) +} +``` + +**Pattern**: `reply` and `root` markers establish thread hierarchy. + +## Cryptography + +### Signing (secp256k1) + +```kotlin +interface ISigner { + suspend fun sign(template: EventTemplate): Event +} + +// Local key signing +class LocalSigner(private val privateKey: ByteArray) : ISigner { + override suspend fun sign(template: EventTemplate): Event { + val id = template.generateId() + val sig = Secp256k1.sign(id, privateKey) + return Event(id, pubKey, createdAt, kind, tags, content, sig) + } +} +``` + +**Pattern**: Signers abstract key management. Can be local, remote (NIP-46), or hardware. + +### Encryption (NIP-44) + +```kotlin +// Modern encryption (ChaCha20-Poly1305) +object Nip44v2 { + fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String + fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String +} + +// Usage +val encrypted = Nip44v2.encrypt( + plaintext = "Secret message", + privateKey = myPrivateKey, + pubKey = recipientPubKey +) + +val decrypted = Nip44v2.decrypt( + ciphertext = encrypted, + privateKey = myPrivateKey, + pubKey = senderPubKey +) +``` + +**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD. + +### NIP-04 (Deprecated) + +```kotlin +// Legacy encryption (NIP-04, deprecated for NIP-44) +object Nip04 { + fun encrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String + fun decrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String +} +``` + +**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues. + +## Bech32 Encoding (NIP-19) + +```kotlin +object Nip19 { + // Encode + fun npubEncode(pubkey: HexKey): String // npub1... + fun nsecEncode(privateKey: ByteArray): String // nsec1... + fun noteEncode(eventId: HexKey): String // note1... + fun neventEncode(eventId: HexKey, relays: List = emptyList()): String + fun nprofileEncode(pubkey: HexKey, relays: List = emptyList()): String + fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List = emptyList()): String + + // Decode + fun decode(bech32: String): Nip19Result +} + +sealed class Nip19Result { + data class NPub(val hex: HexKey) : Nip19Result() + data class NSec(val hex: HexKey) : Nip19Result() + data class Note(val hex: HexKey) : Nip19Result() + data class NEvent(val hex: HexKey, val relays: List) : Nip19Result() + data class NProfile(val hex: HexKey, val relays: List) : Nip19Result() + data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List) : Nip19Result() +} +``` + +**Usage**: +```kotlin +// Encode +val npub = Nip19.npubEncode(pubkeyHex) +// Output: "npub1..." + +// Decode +when (val result = Nip19.decode(npub)) { + is Nip19Result.NPub -> println("Pubkey: ${result.hex}") + is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}") + else -> println("Other type") +} +``` + +## Event Validation + +```kotlin +fun Event.verify(): Boolean { + // 1. Verify ID matches content hash + val computedId = generateId() + if (id != computedId) return false + + // 2. Verify signature + return Secp256k1.verify(id, sig, pubKey) +} + +fun Event.generateId(): HexKey { + val serialized = serializeForId() // JSON array format + return sha256(serialized) +} +``` + +**Pattern**: Always verify events from untrusted sources (relays). + +## Common Workflows + +### Publishing an Event + +```kotlin +suspend fun publishNote(content: String, signer: ISigner, relays: List) { + // 1. Create template + val template = eventTemplate(kind = 1, content = content) + + // 2. Sign + val event = signer.sign(template) + + // 3. Verify (optional but recommended) + require(event.verify()) { "Signature verification failed" } + + // 4. Publish to relays + relays.forEach { relay -> + relayClient.send(relay, event) + } +} +``` + +### Querying Events + +```kotlin +// Subscription filter +data class Filter( + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, + val since: Long? = null, + val until: Long? = null, + val limit: Int? = null, + val tags: Map>? = null // e.g., {"#e": [eventId], "#p": [pubkey]} +) + +// Usage +val filter = Filter( + authors = listOf(userPubkey), + kinds = listOf(1), // Text notes only + limit = 50 +) + +relayClient.subscribe(relay, filter) { event -> + // Handle incoming events +} +``` + +### Creating a Zap (NIP-57) + +```kotlin +fun createZapRequest( + targetEvent: Event, + amountSats: Long, + comment: String = "" +): EventTemplate { + return eventTemplate( + kind = 9734, // Zap request + content = comment, + tags = tagArray { + add(arrayOf("e", targetEvent.id)) + add(arrayOf("p", targetEvent.pubKey)) + add(arrayOf("amount", "${amountSats * 1000}")) // millisats + add(arrayOf("relays", "wss://relay1.com", "wss://relay2.com")) + } + ) +} +``` + +### Gift-Wrapped DMs (NIP-17) + +```kotlin +fun createGiftWrappedDM( + recipientPubkey: HexKey, + message: String, + signer: ISigner +): Event { + // 1. Create sealed gossip (kind 14) + val sealedGossip = createSealedGossip(message, recipientPubkey, signer) + + // 2. Wrap in gift wrap (kind 1059) + return createGiftWrap(sealedGossip, recipientPubkey, signer) +} +``` + +**Pattern**: Double encryption + random ephemeral keys for metadata protection. + +## Finding NIPs + +Use the bundled script: + +```bash +# Find by NIP number +scripts/nip-lookup.sh 44 + +# Search by term +scripts/nip-lookup.sh encryption +scripts/nip-lookup.sh "gift wrap" +``` + +Or see `references/nip-catalog.md` for complete catalog. + +## Bundled Resources + +- **references/nip-catalog.md** - All 57 NIPs with package locations and key files +- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types +- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns +- **scripts/nip-lookup.sh** - Find NIP implementations by number or search term + +## Quick Reference + +| Task | Pattern | Location | +|------|---------|----------| +| Create event | `eventTemplate(kind, content, tags)` | nip01Core/signers/ | +| Build tags | `tagArray { add(...) }` | nip01Core/core/ | +| Sign event | `signer.sign(template)` | nip01Core/signers/ | +| Verify signature | `event.verify()` | nip01Core/core/ | +| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ | +| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ | +| Find NIP | `scripts/nip-lookup.sh ` | - | + +## Common Event Kinds + +| Kind | Type | NIP | Package | +|------|------|-----|---------| +| 0 | Metadata | 01 | nip01Core/ | +| 1 | Text note | 01, 10 | nip10Notes/ | +| 3 | Contact list | 02 | nip02FollowList/ | +| 5 | Deletion | 09 | nip09Deletions/ | +| 7 | Reaction | 25 | nip25Reactions/ | +| 1059 | Gift wrap | 59 | nip59Giftwrap/ | +| 9734 | Zap request | 57 | nip57Zaps/ | +| 9735 | Zap receipt | 57 | nip57Zaps/ | +| 10002 | Relay list | 65 | nip65RelayList/ | +| 30023 | Long-form content | 23 | nip23LongContent/ | + +## Related Skills + +- **nostr-protocol** - NIP specifications and protocol details +- **kotlin-expert** - Kotlin patterns (@Immutable, sealed classes, DSLs) +- **kotlin-coroutines** - Async patterns for relay communication +- **kotlin-multiplatform** - KMP patterns, expect/actual in Quartz diff --git a/.claude/skills/nostr-expert/references/event-hierarchy.md b/.claude/skills/nostr-expert/references/event-hierarchy.md new file mode 100644 index 000000000..1ca0c0fa1 --- /dev/null +++ b/.claude/skills/nostr-expert/references/event-hierarchy.md @@ -0,0 +1,293 @@ +# Event Hierarchy & Structure + +## Core Hierarchy + +``` +IEvent (empty interface) + └── Event (@Immutable base class) + ├── BaseAddressableEvent (replaceable + addressable, has d-tag) + │ ├── BaseReplaceableEvent (kinds 10000-20000, FIXED_D_TAG = "") + │ └── [Specific addressable events - 30000-40000] + └── [Specific event implementations - all other kinds] +``` + +## Event Base Class + +**Location**: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt` + +```kotlin +@Immutable +open class Event( + val id: HexKey, // SHA-256 hash of serialized event + val pubKey: HexKey, // Author's public key (32 bytes hex) + val createdAt: Long, // Unix timestamp + val kind: Kind, // Event kind (Int typealias) + val tags: TagArray, // Array of tag arrays + val content: String, // Event content + val sig: HexKey, // schnorr signature (64 bytes hex) +) : IEvent, OptimizedSerializable +``` + +## Kind Classification + +```kotlin +typealias Kind = Int + +fun Kind.isEphemeral() = this in 20000..29999 +fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999 +fun Kind.isAddressable() = this in 30000..39999 +fun Kind.isRegular() = this in 1000..9999 +``` + +## Common Event Types + +### Text Note (kind 1) +```kotlin +class TextNoteEvent(...) : BaseThreadedEvent(...), + EventHintProvider, AddressHintProvider, PubKeyHintProvider, SearchableEvent + +// Threading support via markers: reply, root, mention +fun replyTo(): List // Direct reply targets +fun root(): Note? // Root of thread +``` + +### Metadata (kind 0) +```kotlin +class MetadataEvent(...) : BaseAddressableEvent(...) + +// Replaceable: newest version overwrites old +// d-tag automatically set to "" for kind 0 +fun name(): String? +fun displayName(): String? +fun picture(): String? +fun about(): String? +fun lnAddress(): String? +``` + +### Reaction (kind 7) +```kotlin +class ReactionEvent(...) : Event(...) + +companion object { + const val LIKE = "+" + const val DISLIKE = "-" + + fun like(reactedTo: EventHintBundle, ...) + fun dislike(reactedTo: EventHintBundle, ...) +} +``` + +### Zap Request/Receipt (kinds 9734, 9735) +```kotlin +class LnZapRequestEvent(...) : Event(...) + // Created by client, sent to Lightning Address + +class LnZapEvent(...) : Event(...) + // Receipt from LSP, contains bolt11 + embedded zap request + val zapRequest: LnZapRequestEvent? by lazy { containedPost() } + val amount: BigDecimal? by lazy { /* parse from bolt11 */ } +``` + +### Long-Form Content (kind 30023) +```kotlin +class LongTextNoteEvent(...) : BaseAddressableEvent(...) + // Blog posts, articles + // Addressable via kind:pubkey:d-tag +``` + +### Lists (kinds 10000-30004) +```kotlin +sealed class PeopleListEvent : BaseAddressableEvent { + object MuteList : PeopleListEvent(10000) + object PinList : PeopleListEvent(10001) + object BookmarkList : PeopleListEvent(10003) + // ... 18 list types total +} +``` + +## Event Interfaces + +### Hint Providers +Events can implement interfaces to optimize relay queries: + +```kotlin +interface EventHintProvider { + fun taggedEventIds(): Set + fun taggedEventRelays(): Map> +} + +interface PubKeyHintProvider { + fun taggedPubKeys(): Set + fun taggedPubKeyRelays(): Map> +} + +interface AddressHintProvider { + fun taggedAddresses(): Set
+ fun taggedAddressRelays(): Map> +} + +interface SearchableEvent { + fun subject(): String? + fun isContentEncoded(): Boolean +} +``` + +## Event Building Pattern + +### DSL Builder +```kotlin +TextNoteEvent.build( + note = "Hello Nostr", + replyingTo = eventBundle, + createdAt = TimeUtils.now() +) { + pTag(pubKey, relayHint) // Tag person + eTag(eventId, relayHint, "reply") // Tag event with marker + hashtag("nostr") // Add hashtag + alt("A short note") // Alt text +} +``` + +### Event Template (Low-level) +```kotlin +suspend fun eventTemplate( + kind: Kind, + content: String, + createdAt: Long, + initializer: TagArrayBuilder.() -> Unit +): EventTemplate { + val tags = TagArrayBuilder().apply(initializer).build() + return EventTemplate(kind, tags, content, createdAt) +} + +// Sign with signer +val template = eventTemplate(1, "Hello", now()) { pTag(pubkey) } +val signedEvent = signer.sign(template) +``` + +## Addressable vs Regular Events + +| Feature | Regular Event | Addressable Event | +|---------|---------------|-------------------| +| **Identifier** | Event ID (SHA-256 hash) | Address (kind:pubkey:d-tag) | +| **Replaceability** | Immutable | Newest replaces old | +| **d-tag** | Optional | Required | +| **Lookup** | By event ID | By address | +| **Example** | Text note (kind 1) | Metadata (kind 0), Long-form (kind 30023) | + +```kotlin +// Regular event address +note = LocalCache.getNoteIfExists(eventId) + +// Addressable event address +address = Address(kind = 30023, pubkey = authorHex, dTag = "my-article") +note = LocalCache.getAddressableNoteIfExists(address) +``` + +## Event Validation + +```kotlin +// Verify event ID matches computed hash +fun Event.verifyId(): Boolean = + EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content) + +// Verify signature +fun Event.verifySignature(): Boolean = + Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) + +// Complete verification +fun Event.checkSignature() { + if (!verifyId()) throw Exception("ID mismatch") + if (!verifySignature()) throw Exception("Bad signature!") +} +``` + +## Event Serialization + +```kotlin +// To JSON (for transmission/signing) +fun Event.toJson(): String = OptimizedJsonMapper.toJson(this) + +// From JSON +fun Event.fromJson(json: String): Event = OptimizedJsonMapper.fromJson(json) + +// Event ID generation (SHA-256 of canonical JSON) +fun EventHasher.hashId( + pubKey: HexKey, + createdAt: Long, + kind: Kind, + tags: TagArray, + content: String +): HexKey { + val serialized = """[0,"$pubKey",$createdAt,$kind,${tags.toJson()},"$content"]""" + return sha256(serialized.encodeToByteArray()).toHexKey() +} +``` + +## Event Lifecycle in LocalCache + +``` +Event received from relay + ↓ +LocalCache.consume(event, relay, wasVerified) + ↓ +getOrCreateNote(event.id) or getOrCreateAddressableNote(address) + ↓ +justVerify(event) → checkSignature() + ↓ +note.loadEvent(event, author, replyTo) + ↓ +Update indices (replies, reactions, boosts) + ↓ +refreshNewNoteObservers(note) → emit to SharedFlow + ↓ +UI updates +``` + +## Common Event Patterns + +### Reply Threading +```kotlin +// Root event (top of thread) +val rootEvent = TextNoteEvent.build("Thread root") { } + +// Reply to root +val reply1 = TextNoteEvent.build("First reply", replyingTo = rootEvent) { + // Automatically adds: + // ["e", , , "root"] + // ["e", , , "reply"] +} + +// Reply to reply (nested) +val reply2 = TextNoteEvent.build("Nested reply", replyingTo = reply1) { + // Automatically adds: + // ["e", , , "root"] + // ["e", , , "reply"] +} +``` + +### Replaceable Events +```kotlin +// Metadata update (kind 0) - newest wins +val metadata1 = MetadataEvent.createNew(name = "Alice", picture = "url1") +Thread.sleep(1000) +val metadata2 = MetadataEvent.createNew(name = "Alice Updated", picture = "url2") + +// LocalCache keeps only metadata2 (higher createdAt) +``` + +### Event Deletion +```kotlin +// Delete events +val deletion = DeletionEvent.create( + deleteEvents = listOf(eventId1, eventId2), + reason = "Spam", + signer = signer +) + +// LocalCache marks events as deleted, but doesn't remove (for verification) +``` + +## 63+ Event Classes + +Full list at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip*/` - one class per event type across 60+ NIP implementations. diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md new file mode 100644 index 000000000..950f2cb1a --- /dev/null +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -0,0 +1,179 @@ +# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz + +## Standard NIPs by Category + +### Core/Basic Protocol +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 01 | `nip01Core/` | Event.kt, Kind.kt, Tag.kt | Core protocol, event structure, kinds, tags | +| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) | +| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps | +| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) | +| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification | +| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation | +| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) | +| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata | +| 13 | `nip13Pow/` | ProofOfWork.kt | Proof of work | +| 14 | `nip14Subject/` | Subject tags | Subject tags for text notes | +| 17 | `nip17Dm/` | GiftWrapEvent.kt, SealedGossipEvent.kt | Private DMs (replacem + +ent for NIP-04) | +| 21 | `nip21UriScheme/` | URI scheme (`nostr:`) | URI scheme parsing | +| 42 | `nip42RelayAuth/` | RelayAuthEvent.kt | Relay authentication (kind 22242) | +| 44 | `nip44Encryption/` | Nip44.kt, Nip44v2.kt | Modern encryption (ChaCha20) | +| 49 | `nip49PrivKeyEnc/` | NIP-49Ncryptsec.kt | Private key encryption format | + +### Content Types +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 10 | `nip10Notes/` | TextNoteEvent.kt | Text notes with threading (kind 1) | +| 18 | `nip18Reposts/` | RepostEvent.kt, GenericRepostEvent.kt | Reposts (kind 6, 16) | +| 22 | `nip22Comments/` | CommentEvent.kt | Comments (kind 1111) | +| 23 | `nip23LongContent/` | LongTextNoteEvent.kt | Long-form content (kind 30023) | +| 25 | `nip25Reactions/` | ReactionEvent.kt | Reactions (kind 7) | +| 31 | `nip31Alts/` | Alt tags | Alt description tags | +| 36 | `nip36SensitiveContent/` | Content warnings | Content warning tags | +| 37 | `nip37Drafts/` | DraftEvent.kt | Drafts (kind 31234) | +| 50 | `nip50Search/` | Search filters | Full-text search | + +### Encoding & Standards +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 19 | `nip19Bech32/` | Nip19.kt | Bech32 encoding (npub, nsec, note, nevent, nprofile, naddr) | +| 40 | `nip40Expiration/` | Expiration tags | Event expiration | +| 48 | `nip48ProxyTags/` | Proxy tags | Proxy tags for delegation | +| 62 | `nip62RequestToVanish/` | RequestToVanishEvent.kt | Request to vanish (kind 12) | +| 98 | `nip98HttpAuth/` | HTTP authorization | HTTP auth header | + +### Lists & Management +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 51 | `nip51Lists/` | 18 list types | Named lists (mute, bookmarks, pins, communities, etc.) (kinds 10000-30004) | +| 65 | `nip65RelayList/` | AdvertisedRelayListEvent.kt | Relay lists (kind 10002) | + +### Social & Identity +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 39 | `nip39ExtIdentities/` | External identities | External identity claims | +| 46 | `nip46RemoteSigner/` | NostrConnectEvent.kt | Remote signer protocol (bunker) | +| 47 | `nip47WalletConnect/` | Nostr Wallet Connect | Wallet connection protocol | +| 56 | `nip56Reports/` | ReportEvent.kt | Reports (kind 1984) | +| 57 | `nip57Zaps/` | LnZapEvent.kt, LnZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) | +| 58 | `nip58Badges/` | Badge events | Badge definitions & awards (kinds 30009, 8) | +| 59 | `nip59Giftwrap/` | GiftWrapEvent.kt | Gift-wrapped events for privacy | +| 75 | `nip75ZapGoals/` | ZapGoalEvent.kt | Zap goals (kind 9041) | + +### Specialized Content +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 28 | `nip28PublicChat/` | ChannelCreateEvent.kt, ChannelMessageEvent.kt | Public chat channels (kinds 40-44) | +| 30 | `nip30CustomEmoji/` | EmojiUrl.kt | Custom emoji | +| 34 | `nip34Git/` | Git patch/issue events | Git repository tracking (kinds 30617, 30618, 1617, 1621, 1622, 1630, 1633) | +| 35 | `nip35Torrents/` | Torrent events | Torrent tracking | +| 52 | `nip52Calendar/` | Calendar events | Calendar time-based/date-based (kinds 31922-31925) | +| 53 | `nip53LiveActivities/` | LiveActivitiesEvent.kt | Live events/streaming (kind 30311) | +| 54 | `nip54Wiki/` | WikiNoteEvent.kt | Wiki pages (kind 30818) | +| 68 | `nip68Picture/` | Picture metadata | Picture metadata | +| 71 | `nip71Video/` | 7 video event types | Video events (kinds 34235, 35235, 1234, 1235) | +| 72 | `nip72ModCommunities/` | Community events | Moderated communities (kinds 34550, 34551, 9041) | +| 84 | `nip84Highlights/` | HighlightEvent.kt | Highlights (kind 9802) | +| 89 | `nip89AppHandlers/` | AppDefinitionEvent.kt | App recommendations (kinds 31990, 31989) | +| 90 | `nip90Dvms/` | DVM job events | Data Vending Machines (DVMs) (kinds 5000-7000) | +| 92 | `nip92IMeta/` | IMeta tags | Image metadata tags | +| 94 | `nip94FileMetadata/` | FileHeaderEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) | +| 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage | +| 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) | +| A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events | +| B7 | `nipB7Blossom/` | Blossom server URLs | Blossom file storage | + +### Web/Storage/Other +| NIP | Directory | Key Files | Description | +|-----|-----------|-----------|-------------| +| 38 | `nip38UserStatus/` | StatusEvent.kt | User status (kind 30315) | +| 60 | `nip60Payment/` | Wallet events | Wallet info (kind 13194) | +| 61 | `nip61PaymentRequest/` | Nut zaps | Cashu payment requests | +| 64 | `nip64Chess/` | Chess moves | Chess move events | +| 66 | `nip66Monitoring/` | Relay monitor events | Relay monitoring | +| 67 | `nip67Invoices/` | Invoice tags | Lightning invoice tags | +| 69 | `nip69Offers/` | BOLT-12 offers | BOLT-12 offer tags | +| 70 | `nip70ProtectedEvts/` | Protected events | Protected event types | +| 73 | `nip73ExternalIds/` | External content IDs | External content identifiers | +| 78 | `nip78AppData/` | AppDataEvent.kt | Application data (kind 30078) | +| 79 | `nip79Labels/` | Label events | Labeling (kinds 1985, 1986) | +| 80-88 | Various | Various protocols | Relationship, preferences, polls, surveys, social graphs, etc. | +| 91 | `nip91Feed/` | Feed display events | Feed definitions | +| 93 | `nip93Gallery/` | Gallery events | Gallery collections | +| 95 | `nip95Storage/` | Storage event tags | Storage events | +| 97 | `nip97Nests/` | Audio rooms | Audio room events | + +## Experimental NIPs (18 packages) + +Located at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/`: + +| Package | Description | +|---------|-------------| +| `audio/` | Audio content, track events | +| `bounties/` | Bounty/funding events | +| `decoupling/` | Decoupling setup | +| `edits/` | Event edit tracking | +| `ephemChat/` | Ephemeral encrypted chat | +| `forks/` | Fork tracking | +| `inlineMetadata/` | Inline metadata | +| `interactiveStories/` | Interactive story events | +| `limits/` | Limit enforcement | +| `medical/` | Medical data | +| `nip95/` | File storage support | +| `nipA3/` | A3 protocol extension | +| `nns/` | Nostr Name System | +| `profileGallery/` | Profile gallery lists | +| `publicMessages/` | Public message lists | +| `relationshipStatus/` | Relationship status events | +| `trustedAssertions/` | Trust/assertion events | +| `zapPolls/` | Zap-based polling | + +## Quick Lookup by Kind + +| Kind | Event Type | NIP | +|------|------------|-----| +| 0 | Metadata | 01 | +| 1 | Text Note | 01, 10 | +| 3 | Follow List | 02 | +| 4 | Encrypted DM (legacy) | 04 | +| 5 | Deletion | 09 | +| 6 | Repost | 18 | +| 7 | Reaction | 25 | +| 8 | Badge Award | 58 | +| 16 | Generic Repost | 18 | +| 40-44 | Channel Events | 28 | +| 1063 | File Metadata | 94 | +| 1111 | Comment | 22 | +| 1617, 1621, 1622, 1630, 1633 | Git | 34 | +| 1984 | Report | 56 | +| 1985, 1986 | Label | 79 | +| 9734 | Zap Request | 57 | +| 9735 | Zap Receipt | 57 | +| 9802 | Highlight | 84 | +| 10000-20000 | Replaceable Lists | 51 | +| 10002 | Relay List | 65 | +| 13194 | Wallet Info | 60 | +| 22242 | Relay Auth | 42 | +| 23194, 23195 | NWC Payment | 47 | +| 30000-40000 | Addressable Events | Various | +| 30009 | Badge Definition | 58 | +| 30023 | Long-Form Content | 23 | +| 30078 | App Data | 78 | +| 30311 | Live Event | 53 | +| 30315 | User Status | 38 | +| 30402 | Classifieds | 99 | +| 30818 | Wiki | 54 | +| 31234 | Draft | 37 | +| 31922-31925 | Calendar | 52 | +| 31989, 31990 | App Handlers | 89 | +| 34235, 34550-34551 | Video/Communities | 71, 72 | +| 5000-7000 | DVM Jobs | 90 | + +## File Location Pattern + +All NIPs located at: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip/` + +Example: NIP-57 → `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/` diff --git a/.claude/skills/nostr-expert/references/tag-patterns.md b/.claude/skills/nostr-expert/references/tag-patterns.md new file mode 100644 index 000000000..9279a9c22 --- /dev/null +++ b/.claude/skills/nostr-expert/references/tag-patterns.md @@ -0,0 +1,336 @@ +# Tag Patterns in Quartz + +Tags are the primary way events reference other events, users, and metadata in Nostr. + +## Tag Structure + +```kotlin +typealias Tag = Array // ["tag_name", "value", "optional_param", ...] +typealias TagArray = Array +``` + +**Pattern**: `[name, value, ...optionalParams]` + +## TagArrayBuilder DSL + +```kotlin +fun tagArray(initializer: TagArrayBuilder.() -> Unit): TagArray +``` + +**Methods**: +- `add(tag)` - Append tag +- `addFirst(tag)` - Prepend tag +- `addUnique(tag)` - Replace all tags with this name +- `remove(tagName)` - Remove all tags with name +- `removeIf(predicate, toCompare)` - Conditional removal + +**Example**: +```kotlin +val tags = tagArray { + add(arrayOf("e", eventId, relayHint, "reply")) + add(arrayOf("p", pubkey)) + addUnique(arrayOf("subject", "Hello")) +} +``` + +## Core Tag Types (NIP-01) + +### e-tag (Event Reference) +```kotlin +// ["e", , , ] +arrayOf("e", eventId, "wss://relay.damus.io", "reply") +``` + +**Markers** (NIP-10): +- `root` - Root of thread +- `reply` - Direct reply target +- `mention` - Mentioned event (not reply) + +**Extensions**: +```kotlin +// nip01Core/tags/ +fun TagArrayBuilder.eTag(eventId: HexKey, relay: String? = null, marker: String? = null) +``` + +### p-tag (Pubkey Reference) +```kotlin +// ["p", , ] +arrayOf("p", pubkey, "wss://relay.damus.io") +``` + +**Usage**: Tag users, indicate recipients + +**Extensions**: +```kotlin +fun TagArrayBuilder.pTag(pubkey: HexKey, relay: String? = null) +``` + +### a-tag (Addressable Event Reference) +```kotlin +// ["a", ::, ] +arrayOf("a", "30023:${authorPubkey}:${dtag}", "wss://relay.damus.io") +``` + +**Usage**: Reference replaceable/addressable events (kinds 10000-20000, 30000-40000) + +**Extensions**: +```kotlin +fun TagArrayBuilder.aTag(kind: Int, pubkey: HexKey, dTag: String, relay: String? = null) +``` + +### d-tag (Identifier) +```kotlin +// ["d", ] +arrayOf("d", "my-article-slug") +``` + +**Usage**: Unique identifier for addressable events + +## Common Tag Extensions + +### Subject (NIP-14) +```kotlin +// nip14Subject/ +fun Event.subject(): String? +fun TagArrayBuilder.subject(text: String) +``` + +### Content Warning (NIP-36) +```kotlin +// nip36SensitiveContent/ +fun Event.contentWarning(): String? +fun TagArrayBuilder.contentWarning(reason: String = "") +``` + +### Expiration (NIP-40) +```kotlin +// nip40Expiration/ +fun Event.expiration(): Long? +fun TagArrayBuilder.expiration(unixTimestamp: Long) +``` + +### Alt Description (NIP-31) +```kotlin +// nip31Alts/ +fun Event.alt(): String? +fun TagArrayBuilder.alt(description: String) +``` + +## Specialized Tags + +### Zap Tags (NIP-57) +```kotlin +// nip57Zaps/tags/ +class BoltTag(val bolt11: String, val preimage: String?) +class DescriptionTag(val zapRequestJson: String) +``` + +### Imeta Tags (NIP-92) +```kotlin +// nip92IMeta/ +class IMetaTag(val url: String, val metadata: Map) + +// Usage: Image metadata +IMetaTag("https://example.com/image.jpg", mapOf( + "m" to "image/jpeg", + "dim" to "1920x1080", + "blurhash" to "..." +)) +``` + +### Relay Tags (NIP-65) +```kotlin +// nip65RelayList/ +class RelayTag(val url: String, val type: RelayType) +enum class RelayType { READ, WRITE, BOTH } +``` + +## Tag Query Patterns + +### Finding Tags +```kotlin +// Extension functions on TagArray +fun TagArray.firstTag(name: String): Tag? +fun TagArray.allTags(name: String): List +fun TagArray.tagValue(name: String): String? +fun TagArray.tagValues(name: String): List +``` + +**Example**: +```kotlin +val event: TextNoteEvent = ... +val subject = event.tags.tagValue("subject") +val mentions = event.tags.allTags("p").mapNotNull { it.getOrNull(1) } +``` + +### Parsing Tags +```kotlin +// Pattern: Companion object with parse methods +object ETag { + fun parse(tag: Tag): ETag? { + if (tag.getOrNull(0) != "e") return null + return ETag( + eventId = tag.getOrNull(1) ?: return null, + relay = tag.getOrNull(2), + marker = tag.getOrNull(3) + ) + } +} + +// Usage +val eTags = event.tags.mapNotNull(ETag::parse) +``` + +## Event Builder Pattern + +Combining TagArrayBuilder with event creation: + +```kotlin +fun createTextNote(content: String, replyTo: Event?): EventTemplate { + return eventTemplate( + kind = 1, + content = content, + tags = tagArray { + replyTo?.let { + eTag(it.id, marker = "reply") + pTag(it.pubKey) + it.rootEvent()?.let { root -> + eTag(root.id, marker = "root") + } + } + } + ) +} +``` + +## Hint System + +Tags can provide "hints" - optional relay URLs for fetching referenced content: + +```kotlin +// Event references +["e", eventId, "wss://relay.example.com"] // relay hint + +// Pubkey references +["p", pubkey, "wss://relay.example.com"] // relay hint + +// Addressable references +["a", "30023:pubkey:dtag", "wss://relay.example.com"] // relay hint +``` + +**Pattern**: Third parameter (index 2) is always the relay hint + +## Tag Validation + +```kotlin +// Common validations +fun validateETag(tag: Tag): Boolean { + return tag.getOrNull(0) == "e" && tag.getOrNull(1)?.isValidHex() == true +} + +fun validatePTag(tag: Tag): Boolean { + return tag.getOrNull(0) == "p" && tag.getOrNull(1)?.isValidHex() == true +} +``` + +## Performance Patterns + +### Tag Indexing +```kotlin +// TagArrayBuilder keeps an index by tag name +private val tagList = mutableMapOf>() + +// Fast lookup by name +fun remove(tagName: String) { + tagList.remove(tagName) +} +``` + +### Lazy Parsing +```kotlin +// Don't parse all tags upfront +class TextNoteEvent(...) { + private val _mentions by lazy { + tags.mapNotNull(PTag::parse) + } + + fun mentions() = _mentions +} +``` + +## Common Workflows + +### Creating a Reply +```kotlin +fun replyTo(original: TextNoteEvent, content: String): EventTemplate { + return eventTemplate( + kind = 1, + content = content, + tags = tagArray { + // Reply to this event + eTag(original.id, marker = "reply") + + // Copy root marker if exists, or mark original as root + original.rootEvent()?.let { + eTag(it.id, marker = "root") + } ?: eTag(original.id, marker = "root") + + // Tag author + pTag(original.pubKey) + + // Tag all mentioned users + original.mentions().forEach { pTag(it) } + } + ) +} +``` + +### Creating a Reaction +```kotlin +fun createReaction(targetEvent: Event, emoji: String): EventTemplate { + return eventTemplate( + kind = 7, + content = emoji, + tags = tagArray { + eTag(targetEvent.id) + pTag(targetEvent.pubKey) + } + ) +} +``` + +### Creating an Addressable Event +```kotlin +fun createArticle(title: String, content: String, slug: String): EventTemplate { + return eventTemplate( + kind = 30023, + content = content, + tags = tagArray { + addUnique(arrayOf("d", slug)) // Unique identifier + add(arrayOf("title", title)) + add(arrayOf("published_at", "${TimeUtils.now()}")) + } + ) +} +``` + +## Quick Reference + +| Tag | NIP | Usage | Example | +|-----|-----|-------|---------| +| e | 01 | Event reference | `["e", eventId, relay, marker]` | +| p | 01 | Pubkey reference | `["p", pubkey, relay]` | +| a | 01 | Addressable event | `["a", "kind:pubkey:d"]` | +| d | 01 | Identifier | `["d", "unique-id"]` | +| subject | 14 | Subject line | `["subject", "Hello"]` | +| content-warning | 36 | Content warning | `["content-warning", "nsfw"]` | +| expiration | 40 | Expiration time | `["expiration", "1234567890"]` | +| bolt11 | 57 | Lightning invoice | `["bolt11", "lnbc..."]` | +| imeta | 92 | Media metadata | `["imeta", "url", "m", "image/jpeg"]` | +| relay | 65 | User relays | `["relay", "wss://...", "read"]` | + +## Resources + +- Tag builders: `quartz/src/commonMain/.../nip01Core/tags/` +- Tag extensions: Look for `TagArrayExt.kt`, `TagArrayBuilderExt.kt` in each NIP package +- Event parsing: Each event class has tag parsing methods diff --git a/.claude/skills/nostr-expert/scripts/nip-lookup.sh b/.claude/skills/nostr-expert/scripts/nip-lookup.sh new file mode 100755 index 000000000..1270bc318 --- /dev/null +++ b/.claude/skills/nostr-expert/scripts/nip-lookup.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Find NIP implementation files by NIP number or search term + +set -e + +QUARTZ_PATH="${QUARTZ_PATH:-./quartz/src/commonMain/kotlin/com/vitorpamplona/quartz}" + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + echo "" + echo "Examples:" + echo " $0 01 # Find NIP-01 files" + echo " $0 44 # Find NIP-44 files" + echo " $0 encryption # Search for 'encryption' in NIP packages" + echo "" + echo "Set QUARTZ_PATH to override default quartz location" + exit 1 +fi + +SEARCH_TERM="$1" + +# Check if it's a number (NIP number) +if [[ "$SEARCH_TERM" =~ ^[0-9]+$ ]]; then + # Pad to 2 digits + NIP_NUM=$(printf "%02d" "$SEARCH_TERM") + echo "Searching for NIP-$NIP_NUM implementation..." + echo "================================================" + echo "" + + # Find directories matching nip##* + find "$QUARTZ_PATH" -type d -name "nip${NIP_NUM}*" | while read -r dir; do + echo "📁 $(basename "$dir")/" + find "$dir" -name "*.kt" -type f | while read -r file; do + rel_path="${file#$QUARTZ_PATH/}" + echo " └─ $rel_path" + done + echo "" + done +else + # Text search + echo "Searching for '$SEARCH_TERM' in NIP packages..." + echo "================================================" + echo "" + + find "$QUARTZ_PATH" -type d -name "nip*" | while read -r dir; do + if grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | head -1 > /dev/null; then + echo "📁 $(basename "$dir")/" + grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | while read -r file; do + rel_path="${file#$QUARTZ_PATH/}" + matches=$(grep -c -i "$SEARCH_TERM" "$file" 2>/dev/null || echo "0") + echo " └─ $rel_path ($matches matches)" + done + echo "" + fi + done +fi + +echo "Done." diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b54d77de..e8fa1598e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,139 @@ + +# [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08 + +- Fixed mixed DMs between logged in users. +- Fixed draft screen click to edit post. + + +# [Release v1.05.0: Bookmark Lists and WoT Scores](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08 + +#Amethyst v1.05.0: Bookmark Lists, Voice Notes, and WoT Scores + +This release introduces Bookmark List management, a complete overhaul of Voice Notes/YakBaks, +and the debut of Web of Trust (WoT) scores for a safer social experience. + +This version adds support for creating, managing, deleting, and viewing multiple bookmark lists, +which include both public and private members. You will find an improved "Bookmarks" menu option in +the sidebar and extra bookmark options in the context menu of each post, allowing you to add posts +directly to one or more individual lists. + +The Voice Notes UI has been redesigned to allow recording directly within the new Post Screen and a +dedicated Voice Reply screen. Users can record a new voice message, preview it with waveform +visualization, re-record if needed, select a media server, and post the reply. You now have full control. + +Amethyst now supports Trusted Assertions. By connecting to a WoT provider, you can see trust scores +and verified follower counts directly on user pictures. This helps filter signal from noise, identifying +reputable accounts to follow, which DMs to open, and which notifications to prioritize. To activate +this, you will need to find a provider capable of computing these scores. While providers are +currently limited and resource-constrained, we hope more will bring their own algorithms to Nostr over time. + +Quartz received a significantly improved database engine capable of sub-microsecond queries using Android's +default SQLite database. The engine is optimized for mobile environments, using as little memory as +possible to avoid impacting other apps. + +In the background, we have begun building Amethyst Desktop. While much work remains, the goal is a +standalone, mouse-first application that moves away from mobile-centric UI layouts. + +New Features +- Trusted Assertions: Added support for trust scores displayed on user profile pictures +- WoT Followers: Displays verified follower counts in user profiles +- Bookmark Lists: Full support for custom lists by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k +- Relay Information: New UI with expanded NIP-11 feature support +- Voice Notes & Replies: Redesigned experience by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Profile Banner: New default banner by @npub1tx5ccpregnm9afq0xaj42hh93xl4qd3lfa7u74v5cdvyhwcnlanqplhd8g +- Native Links: Intercept njump, yakihonne, primal, iris.to, zap.stream, and shosho.live to open directly on Amethyst by @npub1lu4l4wh7482u8vq0u63g2yuj8yqjdq8ne06r432n92rnd22lspcq0cxa32 + +Improvements: +- New in-memory graph-based cache scheme; moved reports and WoT scores to this new system +- Disabled top bar reappearance to prevent feed shifting when navigating between pages +- Lenient Kotlin Serialization to prevent crashes from malformed JSON; +- Removed expired addressable events from cache +- Moves reports from the old caching system to the new Graph-based one. +- Reverted to a 500-post load limit for Profile screens to handle high-reply accounts +- Moved the QR Code screen from a Dialog to a full Route. +- Re-adds name as a tagging name to the profile edit page. + +Performance: +- Faster event id checker by serializing, sha256 hashing, and ID comparison without creating any intermediary buffers. +- Faster event JSON parsers by avoiding new variables and thus garbage collection calls +- Faster tag array Deserializer +- Manages the pool state without having to loop through relays, saving some milliseconds of processing. +- Adds a cache system for WoT scores +- Improved Compose stability for video UI + +BugFixes: +- Fixes JSON serialization of UTF-8 Emoji surrogates for compatibility with standard Nostr implementations +- Improves error message on zap configuration errors with detailed NWC URI by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Centers QR dialog content and reduce excessive top spacing by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw +- Closes subscriptions when ending them on NostrClient instead of waiting for them to finish +- Requires a relay to be an outbox/inbox relay to be able to NOTIFY a user of a payment +- Improves the speed of parsing of invalid kinds inside an address string +- Fixes count not working for LIMIT queries in the DB +- Fixes icon bug with incorrect resource id by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Fixes missing updates to the feed when the top list is not yet available locally +- Fixes List of supported NIPs as Integers on NIP-11 by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Fixes ConcurrentExceptions on event outboxes + +Desktop: +- Base Compose Multiplatform Desktop App with posts and global/following feeds by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c + +Web: +- New website by @npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424 + +Quartz: +- Adds support for Trust Provider lists and Contact Cards for NIP-85 +- Early support for Payment targets as per [NIP-A3](https://github.com/nostr-protocol/nips/pull/2119) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 +- Initial support for NIP 46 by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 +- Adds support for fast MurMur hash 3 64 bits +- Adds a nextLong secure random method +- Removing the generalist approach of ptag-mentions +- Removes deprecated fields in UserMetadata +- Removes compose bom from Quartz to avoid unnecessary dependencies. +- Removes datetime dependencies from Quartz +- Adds dependency on coroutines directly (instead of through compose runtime) +- Removes old secp256 target dependencies +- Adds Default scope for NostrClient and Relay Authenticator + +Quartz-Event Store: +- Moves from text tags to probabilistic 64-bit MurMur Hash3 integers for performance +- Moves from range index queries to kind,pubkey queries by default. +- Adds simpler SQL queries for specific simple Nostr filters +- Expose SQL query plans, vacuum, and analyse to lib users +- Implements AND Tag queries from [NIP-91](https://github.com/nostr-protocol/nips/pull/1365) +- Implements GiftWrap deletions by p-Tag with deletions and vanish requests +- Offers several indexing strategy options to users. +- Adds several test cases that verify not only the SQL but also the indexes used +- Exposes raw queries that return columns for relays that might not need the tag array +- Forces the use of the index on Addressables and Replaceables on triggers +- Fixes duplicated events being returned from the DB +- Fixes unused Or condition in the SQL builder +- Refine the structure of the module classes for the DB +- Removes the Statement cache since statements are not thread safe +- Creating interfaces for multiple EventStores + +Code Quality: +- Updates kotlin, compose, multiplatform, activity, serialization, media3, mockk, secp256, tor, androidxCamera, stdlib +- Adds a compose stability plugin to allow traces in debug +- Updates to the latest Zapstore config +- Updates quarts instructions in the ReadMe. + +Updated translations: +- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm +- Hungarian by @npub1ww8kjxz2akn82qptdpl7glywnchhkx3x04hez3d3rye397turrhssenvtp @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp +- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6 +- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw +- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 +- Latvian by @npub1l60stxkwwmkts76kv02vdjppka9uy6y3paztck7paau7g64l687saaw6av +- Dutch by @npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd +- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz and Alexis Magzalci +- Chinese by @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h + + +# [Release v1.04.2: Fix for Google Play](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.2) - 2025-11-15 + +Quick release for Google. + # [Release v1.04.1: Bugfixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.04.1) - 2025-11-15 diff --git a/README.md b/README.md index 54297b800..2762617d6 100644 --- a/README.md +++ b/README.md @@ -259,16 +259,16 @@ repositories { Add the following line to your `commonMain` dependencies: ```gradle -implementation('com.vitorpamplona.quartz:quartz:') +implementation('com.vitorpamplona.quartz:quartz:1:05.0') ``` Variations to each platform are also available: ```gradle -implementation('com.vitorpamplona.quartz:quartz-android:') -implementation('com.vitorpamplona.quartz:quartz-jvm:') -implementation('com.vitorpamplona.quartz:quartz-iosarm64:') -implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:') +implementation('com.vitorpamplona.quartz:quartz-android:1:05.0') +implementation('com.vitorpamplona.quartz:quartz-jvm:1:05.0') +implementation('com.vitorpamplona.quartz:quartz-iosarm64:1:05.0') +implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1:05.0') ``` Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 744720060..ee42fb543 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -45,9 +45,9 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() - versionCode = 430 - versionName = generateVersionName("1.04.2") - buildConfigField "String", "RELEASE_NOTES_ID", "\"3a03c75d85aaf6b181d3b232d064c4d4feea5c73f0bea2bd91ed61b8da7cd6a6\"" + versionCode = 432 + versionName = generateVersionName("1.05.1") + buildConfigField "String", "RELEASE_NOTES_ID", "\"b457a20195ffcf501389fcb708f0ef73f4ee263e3bba63f1b893a896129e4c79\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index e62736ca8..8f28543b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -200,7 +200,7 @@ class AppModules( val relayStats = RelayStats(client) // Logs debug messages when needed - val detailedLogger = if (isDebug) RelayLogger(client, true, false) else null + val detailedLogger = if (isDebug) RelayLogger(client, false, false) else null val relayReqStats = if (isDebug) RelayReqStats(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index d2678ca68..18916ef15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -248,10 +248,8 @@ object LocalPreferences { withContext(Dispatchers.IO) { val prefsDir = File(prefsDirPath) prefsDir.list()?.forEach { - if (it.contains(npub)) { - if (!File(prefsDir, it).delete()) { - Log.w("LocalPreferences", "Failed to delete preference file: $it") - } + if (it.contains(npub) && !File(prefsDir, it).delete()) { + Log.w("LocalPreferences", "Failed to delete preference file: $it") } } } 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 c38e80510..5b24119a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction +import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache @@ -43,8 +46,6 @@ import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState 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 @@ -205,6 +206,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.CoroutineScope @@ -232,14 +234,21 @@ class Account( val cache: LocalCache, val client: INostrClient, val scope: CoroutineScope, -) { +) : IAccount { private var userProfileCache: User? = null - fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } + override fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it } + + // IAccount interface properties + override val pubKey: String get() = signer.pubKey + override val showSensitiveContent: Boolean? get() = hiddenUsers.flow.value.showSensitiveContent + override val hiddenWordsCase: List get() = hiddenUsers.flow.value.hiddenWordsCase + override val hiddenUsersHashCodes: Set get() = hiddenUsers.flow.value.hiddenUsersHashCodes + override val spammersHashCodes: Set get() = hiddenUsers.flow.value.spammersHashCodes val userMetadata = UserMetadataState(signer, cache, scope, settings) - val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings) + override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings) val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) val localRelayList = LocalRelayListState(signer, cache, scope, settings) @@ -335,7 +344,7 @@ class Account( val allFollows = MergedFollowListsState(kind3FollowList, peopleLists, followLists, hashtagList, geohashList, communityList, scope) val privateDMDecryptionCache = PrivateDMCache(signer) - val privateZapsDecryptionCache = PrivateZapCache(signer) + override val privateZapsDecryptionCache = PrivateZapCache(signer) val draftsDecryptionCache = DraftEventCache(signer) val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) @@ -418,7 +427,7 @@ class Account( val liveNotificationFollowListsPerRelay = OutboxLoaderState(liveNotificationFollowLists, cache, scope).flow - fun isWriteable(): Boolean = settings.isWriteable() + override fun isWriteable(): Boolean = settings.isWriteable() suspend fun updateWarnReports(warnReports: Boolean): Boolean { if (settings.updateWarnReports(warnReports)) { @@ -1554,6 +1563,15 @@ class Account( } } + suspend fun removeBookmark(note: Note) { + if (!isWriteable() || note.isDraft()) return + + val event = bookmarkState.removeBookmark(note) + if (event != null) { + sendMyPublicAndPrivateOutbox(event) + } + } + suspend fun createAuthEvent( relay: NormalizedRelayUrl, challenge: String, 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 902a9015b..67b8afb51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -95,7 +95,7 @@ class AntiSpamFilter { if (spammer.shouldHide() && relay != null) { Amethyst.instance.relayStats .get(relay) - .newSpam("$link1 $link2") + .newSpam(link1, link2) } flowSpam.tryEmit(AntiSpamState(this)) @@ -123,7 +123,7 @@ class AntiSpamFilter { if (spammer.shouldHide() && relay != null) { Amethyst.instance.relayStats .get(relay) - .newSpam("$link1 $link2") + .newSpam(link1, link2) } flowSpam.tryEmit(AntiSpamState(this)) 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 336610ae9..8ea68969b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.amethyst.ui.dal.ListChange import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.cache.LargeCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ListChange.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ListChange.kt new file mode 100644 index 000000000..1c0530571 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ListChange.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.model + +sealed class ListChange { + data class Addition( + val item: T, + ) : ListChange() + + data class Deletion( + val item: T, + ) : ListChange() + + data class SetAddition( + val item: Set, + ) : ListChange() + + data class SetDeletion( + val item: Set, + ) : ListChange() +} 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 5042c45e2..f13edbb7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -31,9 +31,9 @@ import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChanne import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag import com.vitorpamplona.amethyst.model.privateChats.ChatroomList +import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.dateFormatter -import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent @@ -665,6 +666,51 @@ object LocalCache : ILocalCache { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: NipTextEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + 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) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event?.id == event.id) return wasVerified + + if (antiSpam.isSpam(event, relay)) { + return false + } + + if (isVerified || justVerify(event)) { + val replyTo = computeReplyTo(event) + + if (event.createdAt > (note.createdAt() ?: 0L)) { + note.loadEvent(event, author, replyTo) + + refreshNewNoteObservers(note) + + return true + } + } + + return false + } + fun consume( event: LongTextNoteEvent, relay: NormalizedRelayUrl?, @@ -759,7 +805,6 @@ object LocalCache : ILocalCache { fun computeReplyTo(event: Event): List = when (event) { is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } - is WikiNoteEvent -> 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) } @@ -1106,7 +1151,6 @@ object LocalCache : ILocalCache { val new = consumeBaseReplaceable(event, relay, wasVerified) if (new) { - println("AABBCC New ContactCard about ${event.aboutUser()}") val about = checkGetOrCreateUser(event.aboutUser()) ?: return new about.cards().addCard(note) } @@ -2056,6 +2100,10 @@ object LocalCache : ILocalCache { } return notes.filter { _, note -> + if (note.event is AddressableEvent) { + return@filter false + } + if (excludeNoteEventFromSearchResults(note)) { return@filter false } @@ -2853,6 +2901,7 @@ object LocalCache : ILocalCache { is MetadataEvent -> consume(event, relay, wasVerified) is MuteListEvent -> consume(event, relay, wasVerified) is NNSEvent -> consume(event, relay, wasVerified) + is NipTextEvent -> consume(event, relay, wasVerified) is OtsEvent -> consume(event, relay, wasVerified) is PictureEvent -> consume(event, relay, wasVerified) is PrivateDmEvent -> consume(event, relay, wasVerified) @@ -2890,7 +2939,7 @@ object LocalCache : ILocalCache { } } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("LocalCache", "Cannot consume ${event.kind}", e) + Log.w("LocalCache", "Cannot consume ${event.toJson()} from ${relay?.url}", e) false } 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 dd32a2985..d203c59e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -20,1013 +20,10 @@ */ package com.vitorpamplona.amethyst.model -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -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.ui.note.toShortDisplay -import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue -import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward -import com.vitorpamplona.quartz.lightning.LnInvoiceUtil -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists -import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag -import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent -import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag -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.nip28PublicChat.base.IsInPublicChatChannel -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW -import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent -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.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.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -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.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import java.math.BigDecimal - -interface NotesGatherer { - fun removeNote(note: Note) -} - -@Stable -class AddressableNote( - val address: Address, -) : Note(address.toValue()) { - override fun idNote() = toNAddr() - - override fun toNEvent() = toNAddr() - - override fun idDisplayNote() = idNote().toShortDisplay() - - override fun address() = address - - override fun createdAt(): Long? { - val currentEvent = event - if (currentEvent == null) return null - if (currentEvent is PublishedAtProvider) return currentEvent.publishedAt() ?: currentEvent.createdAt - return currentEvent.createdAt - } - - fun dTag(): String = address.dTag - - fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, relayHintUrl()) - - fun toATag() = ATag(address, relayHintUrl()) -} - -@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 - - var reactions = mapOf>() - private set - - var boosts = listOf() - private set - - var reports = mapOf>() - private set - - var zaps = mapOf() - private set - - var zapsAmount: BigDecimal = BigDecimal.ZERO - - var zapPayments = mapOf() - private set - - var relays = listOf() - private set - - open fun idNote() = toNEvent() - - open fun toNEvent(): String { - val myEvent = event - return if (myEvent is WrappedEvent) { - val host = myEvent.host - if (host != null) { - NEvent.create( - host.id, - host.pubKey, - host.kind, - relayHintUrl(), - ) - } else { - NEvent.create(idHex, author?.pubkeyHex, event?.kind, relayHintUrl()) - } - } else { - NEvent.create(idHex, author?.pubkeyHex, event?.kind, relayHintUrl()) - } - } - - fun relayUrls(): List { - val authorRelay = author?.relayHints() ?: emptyList() - - return authorRelay + relays - } - - fun relayUrlsForReactions(): List { - val authorRelay = author?.inboxRelays() ?: emptyList() - - return authorRelay + relays - } - - fun relayHintUrl(): NormalizedRelayUrl? { - val noteEvent = event - val communityPostRelays = - when (noteEvent) { - is CommunityDefinitionEvent -> noteEvent.relayUrls().ifEmpty { null }?.toSet() - is IsInPublicChatChannel -> LocalCache.getAnyChannel(this)?.relays() - else -> null - } - - if (!communityPostRelays.isNullOrEmpty()) return communityPostRelays.firstOrNull() - - val currentOutbox = author?.outboxRelays()?.toSet() - - return if (relays.isNotEmpty()) { - if (currentOutbox != null && currentOutbox.isNotEmpty()) { - val relayMatchesOutbox = relays.firstOrNull { it in currentOutbox } - if (relayMatchesOutbox != null) { - return relayMatchesOutbox - } - } - - return relays.firstOrNull() - } else { - currentOutbox?.firstOrNull() ?: author?.latestMetadataRelay - } - } - - fun toNostrUri(): String = "nostr:${toNEvent()}" - - open fun idDisplayNote() = idNote().toShortDisplay() - - open fun address(): Address? = null - - open fun createdAt() = event?.createdAt - - fun isDraft() = event is DraftWrapEvent - - fun loadEvent( - event: Event, - author: User, - replyTo: List, - ) { - if (this.event?.id != event.id) { - this.event = event - this.author = author - this.replyTo = replyTo - - 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 - flowSet?.replies?.invalidateData() - } - } - - fun removeReply(note: Note) { - if (note in replies) { - replies = replies - note - flowSet?.replies?.invalidateData() - } - } - - fun removeBoost(note: Note) { - if (note in boosts) { - boosts = boosts - note - flowSet?.boosts?.invalidateData() - } - } - - fun removeAllChildNotes(): List { - val repliesChanged = replies.isNotEmpty() - val reactionsChanged = reactions.isNotEmpty() - val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() - val boostsChanged = boosts.isNotEmpty() - val reportsChanged = reports.isNotEmpty() - - val toBeRemoved = - replies + - reactions.values.flatten() + - boosts + - reports.values.flatten() + - zaps.keys + - zaps.values.filterNotNull() + - zapPayments.keys + - zapPayments.values.filterNotNull() - - replies = listOf() - reactions = mapOf>() - boosts = listOf() - reports = mapOf>() - zaps = mapOf() - zapPayments = mapOf() - zapsAmount = BigDecimal.ZERO - relays = listOf() - - 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 - } - - fun removeReaction(note: Note) { - val tags = note.event?.tags ?: emptyArray() - val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" - - if (reactions[reaction]?.contains(note) == true) { - reactions[reaction]?.let { - if (note in it) { - val newList = it.minus(note) - if (newList.isEmpty()) { - reactions = reactions.minus(reaction) - } else { - reactions = reactions + Pair(reaction, newList) - } - - flowSet?.reactions?.invalidateData() - } - } - } - } - - fun removeReport(deleteNote: Note) { - val author = deleteNote.author ?: return - - if (reports[author]?.contains(deleteNote) == true) { - reports[author]?.let { - reports = reports + Pair(author, it.minus(deleteNote)) - flowSet?.reports?.invalidateData() - } - } - } - - fun removeZap(note: Note) { - if (zaps[note] != null) { - zaps = zaps.minus(note) - updateZapTotal() - flowSet?.zaps?.invalidateData() - } else if (zaps.containsValue(note)) { - zaps = zaps.filterValues { it != note } - updateZapTotal() - flowSet?.zaps?.invalidateData() - } - } - - fun removeZapPayment(note: Note) { - if (zapPayments.containsKey(note)) { - zapPayments = zapPayments.minus(note) - flowSet?.zaps?.invalidateData() - } else if (zapPayments.containsValue(note)) { - zapPayments = zapPayments.filterValues { it != note } - flowSet?.zaps?.invalidateData() - } - } - - fun addBoost(note: Note) { - if (note !in boosts) { - boosts = boosts + note - flowSet?.boosts?.invalidateData() - } - } - - @Synchronized - private fun innerAddZap( - zapRequest: Note, - zap: Note?, - ): Boolean { - if (zaps[zapRequest] == null) { - zaps = zaps + Pair(zapRequest, zap) - return true - } - - return false - } - - fun addZap( - zapRequest: Note, - zap: Note?, - ) { - if (zaps[zapRequest] == null) { - val inserted = innerAddZap(zapRequest, zap) - if (inserted && zap != null) { - updateZapTotal() - flowSet?.zaps?.invalidateData() - } - } - } - - @Synchronized - private fun innerAddZapPayment( - zapPaymentRequest: Note, - zapPayment: Note?, - ): Boolean { - if (zapPayments[zapPaymentRequest] == null) { - zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) - return true - } - - return false - } - - fun addZapPayment( - zapPaymentRequest: Note, - zapPayment: Note?, - ) { - checkNotInMainThread() - if (zapPayments[zapPaymentRequest] == null) { - val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) - if (inserted) { - flowSet?.zaps?.invalidateData() - } - } - } - - fun addReaction(note: Note) { - val tags = note.event?.tags ?: emptyArray() - val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" - - val listOfAuthors = reactions[reaction] - if (listOfAuthors == null) { - reactions = reactions + Pair(reaction, listOf(note)) - flowSet?.reactions?.invalidateData() - } else if (!listOfAuthors.contains(note)) { - reactions = reactions + Pair(reaction, listOfAuthors + note) - flowSet?.reactions?.invalidateData() - } - } - - fun addReport(note: Note) { - val author = note.author ?: return - - val reportsByAuthor = reports[author] - - if (reportsByAuthor == null) { - reports = reports + Pair(author, listOf(note)) - flowSet?.reports?.invalidateData() - } else if (!reportsByAuthor.contains(note)) { - reports = reports + Pair(author, reportsByAuthor + note) - flowSet?.reports?.invalidateData() - } - } - - @Synchronized - fun addRelaySync(relay: NormalizedRelayUrl) { - if (relay !in relays) { - relays = relays + relay - } - } - - fun hasRelay(relay: NormalizedRelayUrl) = relay in relays - - fun addRelay(relay: NormalizedRelayUrl) { - if (relay !in relays) { - addRelaySync(relay) - flowSet?.relays?.invalidateData() - } - } - - private suspend fun isPaidByCalculation( - zapPayments: List>, - afterTimeInSeconds: Long, - account: Account, - ): Boolean { - if (zapPayments.isEmpty()) { - return false - } - - return anyAsync(zapPayments) { next -> - val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent - - if (zapResponseEvent != null) { - val response = account.nip47SignerState.decryptResponse(zapResponseEvent) - if (response != null) { - response is PayInvoiceSuccessResponse && - account.nip47SignerState.isNIP47Author(zapResponseEvent.requestAuthor()) && - zapResponseEvent.createdAt > afterTimeInSeconds - } else { - false - } - } else { - false - } - } - } - - private suspend fun isZappedByCalculation( - option: Int?, - user: User, - afterTimeInSeconds: Long, - account: Account, - zapEvents: Map, - ): Boolean { - if (zapEvents.isEmpty()) { - return false - } - - val parallelDecrypt = mutableListOf>() - - zapEvents.forEach { next -> - val zapRequest = next.key.event as LnZapRequestEvent - val zapEvent = next.value?.event as? LnZapEvent - - if (zapEvent != null) { - if (!zapRequest.isPrivateZap()) { - // public events - if (zapRequest.pubKey == user.pubkeyHex && - zapEvent.createdAt > afterTimeInSeconds && - (option == null || option == zapEvent.zappedPollOption()) - ) { - return true - } - } else { - // private events - - // if has already decrypted - val privateZap = account.privateZapsDecryptionCache.cachedPrivateZap(zapRequest) - if (privateZap != null) { - if (privateZap.pubKey == user.pubkeyHex && - zapEvent.createdAt > afterTimeInSeconds && - (option == null || option == zapEvent.zappedPollOption()) - ) { - return true - } - } else { - if (account.isWriteable()) { - parallelDecrypt.add(Pair(zapRequest, zapEvent)) - } - } - } - } - } - - if (parallelDecrypt.isEmpty()) { - return false - } - - return anyAsync(parallelDecrypt) { pair -> - val result = account.privateZapsDecryptionCache.decryptPrivateZap(pair.first) - result?.pubKey == user.pubkeyHex && - pair.second.createdAt > afterTimeInSeconds && - (option == null || option == pair.second.zappedPollOption()) - } - } - - suspend fun isZappedBy( - user: User, - afterTimeInSeconds: Long, - account: Account, - ): Boolean { - val first = isZappedByCalculation(null, user, afterTimeInSeconds, account, zaps) - if (first) return true - if (account.userProfile() == user) { - return isPaidByCalculation(zapPayments.toList(), afterTimeInSeconds, account) - } - return false - } - - suspend fun isZappedBy( - option: Int?, - user: User, - afterTimeInSeconds: Long, - account: Account, - ): Boolean = isZappedByCalculation(option, user, afterTimeInSeconds, account, zaps) - - fun getReactionBy(user: User): String? = - reactions.firstNotNullOfOrNull { - if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) { - it.key - } else { - null - } - } - - fun isBoostedBy(user: User): Boolean = boosts.any { it.author?.pubkeyHex == user.pubkeyHex } - - fun hasReportsBy(user: User): Boolean = reports[user]?.isNotEmpty() ?: false - - fun countReportAuthorsBy(users: Set): Int = reports.count { it.key.pubkeyHex in users } - - fun reportsBy(users: Set): List = - reports - .mapNotNull { - if (it.key.pubkeyHex in users) { - it.value - } else { - null - } - }.flatten() - - private fun updateZapTotal() { - var sumOfAmounts = BigDecimal.ZERO - - // Regular Zap Receipts - zaps.forEach { - val noteEvent = it.value?.event - if (noteEvent is LnZapEvent) { - sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO - } - } - - zapsAmount = sumOfAmounts - } - - private suspend fun zappedAmountCalculation( - startAmount: BigDecimal, - paidInvoiceSet: LinkedHashSet, - zapPayments: List>, - signerState: NwcSignerState, - ): BigDecimal { - if (zapPayments.isEmpty()) { - return startAmount - } - - var output: BigDecimal = startAmount - - launchAndWaitAll(zapPayments) { next -> - val result = - processZapAmountFromResponse( - next.first, - next.second, - signerState, - ) - - if (result != null && !paidInvoiceSet.contains(result.invoice)) { - paidInvoiceSet.add(result.invoice) - output = output.add(result.amount) - } - } - - return output - } - - private suspend fun processZapAmountFromResponse( - paymentRequest: Note, - paymentResponse: Note?, - signerState: NwcSignerState, - ): InvoiceAmount? { - val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent - val nwcResponse = paymentResponse?.event as? LnZapPaymentResponseEvent - - return if (nwcRequest != null && nwcResponse != null) { - processZapAmountFromResponse( - nwcRequest, - nwcResponse, - signerState, - ) - } else { - null - } - } - - class InvoiceAmount( - val invoice: String, - val amount: BigDecimal, - ) - - private suspend fun processZapAmountFromResponse( - nwcRequest: LnZapPaymentRequestEvent, - nwcResponse: LnZapPaymentResponseEvent, - signerState: NwcSignerState, - ): InvoiceAmount? { - // if we can decrypt the reply - return signerState.decryptResponse(nwcResponse)?.let { noteEvent -> - // if it is a sucess - if (noteEvent is PayInvoiceSuccessResponse) { - // if we can decrypt the 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 { - LnInvoiceUtil.getAmountInSats(invoice) - } catch (e: java.lang.Exception) { - if (e is CancellationException) throw e - null - } - - // avoid double counting - if (amount != null) { - InvoiceAmount(invoice, amount) - } else { - null - } - } else { - null - } - } else { - null - } - } - } - - suspend fun zappedAmountWithNWCPayments(signerState: NwcSignerState): BigDecimal { - if (zapPayments.isEmpty()) { - return zapsAmount - } - - val invoiceSet = LinkedHashSet(zaps.size + zapPayments.size) - zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } } - - return zappedAmountCalculation( - zapsAmount, - invoiceSet, - zapPayments.toList(), - signerState, - ) - } - - fun hasReport( - loggedIn: User, - type: ReportType, - ): Boolean = - reports[loggedIn]?.firstOrNull { - it.event is ReportEvent && - (it.event as ReportEvent).reportedAuthor().any { it.type == type } - } != null - - fun hasPledgeBy(user: User): Boolean = - replies - .filter { it.event?.hasAdditionalReward() ?: false } - .any { - val pledgeValue = - try { - BigDecimal(it.event?.content) - } catch (e: Exception) { - if (e is CancellationException) throw e - null - // do nothing if it can't convert to bigdecimal - } - - pledgeValue != null && it.author == user - } - - fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal.ZERO } - - fun hasAnyReports(): Boolean { - val dayAgo = TimeUtils.oneDayAgo() - - if (reports.isNotEmpty()) return true - - return author?.reportsOrNull()?.hasReportNewerThan(dayAgo) ?: false - } - - fun isNewThread(): Boolean = - ( - event is RepostEvent || - event is GenericRepostEvent || - replyTo == null || - replyTo?.size == 0 - ) && - event !is ChannelMessageEvent && - event !is LiveActivitiesChatMessageEvent - - fun hasZapped(loggedIn: User): Boolean = zaps.any { it.key.author == loggedIn } - - fun hasReacted( - loggedIn: User, - content: String, - ): Boolean = allReactionsOfContentByAuthor(loggedIn, content).isNotEmpty() - - fun allReactionsOfContentByAuthor( - loggedIn: User, - content: String, - ): List = reactions[content]?.filter { it.author == loggedIn } ?: emptyList() - - fun allReactionsByAuthor(loggedIn: User): List = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } - - fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { - val fiveMinsAgo = TimeUtils.fiveMinutesAgo() - return boosts.any { - it.author == loggedIn && (it.createdAt() ?: 0L) > fiveMinsAgo - } - } - - fun hasBoostedInTheLast5Minutes(loggedIn: HexKey): Boolean { - val fiveMinsAgo = TimeUtils.fiveMinutesAgo() - return boosts.any { - (it.createdAt() ?: 0L) > fiveMinsAgo && it.author?.pubkeyHex == loggedIn - } - } - - fun boostedBy(loggedIn: User): List = boosts.filter { it.author == loggedIn } - - fun moveAllReferencesTo(note: AddressableNote) { - // migrates these comments to a new version - replies.forEach { - note.addReply(it) - it.replyTo = it.replyTo?.replace(this, note) - } - reactions.forEach { - it.value.forEach { - note.addReaction(it) - it.replyTo = it.replyTo?.replace(this, note) - } - } - boosts.forEach { - note.addBoost(it) - it.replyTo = it.replyTo?.replace(this, note) - } - reports.forEach { - it.value.forEach { - note.addReport(it) - it.replyTo = it.replyTo?.replace(this, note) - } - } - zaps.forEach { - note.addZap(it.key, it.value) - it.key.replyTo = it.key.replyTo?.replace(this, note) - it.value?.replyTo = it.value?.replyTo?.replace(this, note) - } - - replyTo = null - replies = emptyList() - reactions = emptyMap() - boosts = emptyList() - reports = emptyMap() - zaps = emptyMap() - zapsAmount = BigDecimal.ZERO - } - - fun isHiddenFor(accountChoices: HiddenUsersState.LiveHiddenUsers): Boolean { - val thisEvent = event ?: return false - val hash = thisEvent.pubKey.hashCode() - - // if the author is hidden by spam or blocked - if (accountChoices.hiddenUsersHashCodes.contains(hash) || - accountChoices.spammersHashCodes.contains(hash) - ) { - return true - } - - // if the post is sensitive and the user doesn't want to see sensitive content - if (accountChoices.showSensitiveContent == false && thisEvent.isSensitiveOrNSFW()) { - return true - } - - // if this is a repost, consider the inner event. - if ( - thisEvent is GenericRepostEvent || - thisEvent is RepostEvent || - thisEvent is CommunityPostApprovalEvent - ) { - if (replyTo?.lastOrNull()?.isHiddenFor(accountChoices) == true) { - return true - } - } - - if (accountChoices.hiddenWordsCase.isNotEmpty()) { - if (thisEvent is BaseThreadedEvent && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) { - return true - } - - if (thisEvent is CommentEvent) { - thisEvent.isScoped { it.containsAny(accountChoices.hiddenWordsCase) } - } - - if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) { - return true - } - - if (author?.containsAny(accountChoices.hiddenWordsCase) == true) return true - } - - return false - } - - var flowSet: NoteFlowSet? = null - - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = NoteFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet = null - } - } - } - - fun flow(): NoteFlowSet { - if (flowSet == null) { - createOrDestroyFlowSync(true) - } - return flowSet!! - } - - fun clearFlow() { - if (flowSet != null && flowSet?.isInUse() == false) { - createOrDestroyFlowSync(false) - } - } - - fun toETag(): ETag { - val noteEvent = event - return if (noteEvent != null) { - ETag(noteEvent.id, relayHintUrl(), noteEvent.pubKey) - } else { - ETag(idHex, relayHintUrl(), author?.pubkeyHex) - } - } - - fun toEId(): EventReference { - val noteEvent = event - return if (noteEvent != null) { - // uses the confirmed event id if available - EventReference(noteEvent.id, noteEvent.pubKey, relayHintUrl()) - } else { - EventReference(idHex, author?.pubkeyHex, relayHintUrl()) - } - } - - 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 - return if (noteEvent != null) { - MarkedETag(noteEvent.id, relayHintUrl(), marker, noteEvent.pubKey) - } else { - MarkedETag(idHex, relayHintUrl(), marker, author?.pubkeyHex) - } - } -} - -@Stable -class NoteFlowSet( - u: Note, -) { - // Observers line up here. - 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() = - metadata.stateFlow.flatMapLatest { - it.note.author - ?.flow() - ?.metadata - ?.stateFlow ?: MutableStateFlow(null) - } - - fun isInUse(): Boolean = - metadata.hasObservers() || - reports.hasObservers() || - relays.hasObservers() || - reactions.hasObservers() || - boosts.hasObservers() || - replies.hasObservers() || - zaps.hasObservers() || - ots.hasObservers() || - edits.hasObservers() -} - -@Stable -class NoteBundledRefresherFlow( - val note: Note, -) { - // Refreshes observers in batches. - val stateFlow = MutableStateFlow(NoteState(note)) - - fun invalidateData() { - stateFlow.tryEmit(NoteState(note)) - } - - fun hasObservers() = stateFlow.subscriptionCount.value > 0 -} - -@Immutable -class NoteState( - val note: Note, -) - -fun List.eventIdSet() = mapNotNullTo(mutableSetOf()) { it.event?.id } - -fun Array.events() = mapNotNull { it.note.event as? T } - -fun List.events() = mapNotNull { it.event as? T } - -fun List.updateFlow(): Flow> = - if (this.isEmpty()) { - MutableStateFlow(emptyList()) - } else { - combine( - flows = this.map { it.flow().metadata.stateFlow }, - ) { - it.events() - } - } - -public inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolean { - if (this is Collection && isEmpty()) return false - for (note in this) { - val noteEvent = note.event as? T - if (noteEvent != null && predicate(noteEvent)) return true - } - return false -} - -public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean { - if (this is Collection && isEmpty()) return false - for (note in this) { - val noteEvent = note.event - if (noteEvent != null && predicate(noteEvent)) return true - } - return false -} +// 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 NoteFlowSet = com.vitorpamplona.amethyst.commons.model.NoteFlowSet +typealias NoteBundledRefresherFlow = com.vitorpamplona.amethyst.commons.model.NoteBundledRefresherFlow +typealias NoteState = com.vitorpamplona.amethyst.commons.model.NoteState 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 aa694f42d..bfa68c725 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -20,333 +20,10 @@ */ package com.vitorpamplona.amethyst.model -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.model.nip56Reports.UserReportCache -import com.vitorpamplona.amethyst.model.trustedAssertions.UserCardsCache -import com.vitorpamplona.amethyst.ui.note.toShortDisplay -import com.vitorpamplona.quartz.lightning.Lud06 -import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists -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.people.PTag -import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile -import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -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.coroutines.flow.MutableStateFlow -import java.math.BigDecimal - -interface UserDependencies - -@Stable -class User( - val pubkeyHex: String, - val nip65RelayListNote: Note, - val dmRelayListNote: Note, -) { - private var reports: UserReportCache? = null - private var cards: UserCardsCache? = null - - // private var deps = ScatterMap, UserDependencies>() - - var info: UserMetadata? = null - - var latestMetadata: MetadataEvent? = null - var latestMetadataRelay: NormalizedRelayUrl? = null - var latestContactList: ContactListEvent? = null - - var zaps = mapOf() - private set - - var relaysBeingUsed = mapOf() - private set - - var flowSet: UserFlowSet? = null - - fun pubkey() = Hex.decode(pubkeyHex) - - fun pubkeyNpub() = pubkey().toNpub() - - fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay() - - fun dmInboxRelayList() = dmRelayListNote.event as? ChatMessageRelayListEvent - - fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent - - fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) - - fun outboxRelays() = authorRelayList()?.writeRelaysNorm() - - fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay) - - fun inboxRelays() = authorRelayList()?.readRelaysNorm() - - fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays() - - fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: latestMetadataRelay - - fun toPTag() = PTag(pubkeyHex, bestRelayHint()) - - fun toNostrUri() = "nostr:${toNProfile()}" - - fun toBestShortFirstName(): String { - val fullName = toBestDisplayName() - - val names = fullName.split(' ') - - val firstName = - if (names[0].length <= 3) { - // too short. Remove Dr. - "${names[0]} ${names.getOrNull(1) ?: ""}" - } else { - names[0] - } - - return firstName - } - - fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex() - - fun nip05(): String? = info?.nip05 - - fun profilePicture(): String? = info?.picture - - fun updateContactList(event: ContactListEvent) { - if (event.id == latestContactList?.id) return - - val oldContactListEvent = latestContactList - latestContactList = event - - // Update following of the current user - flowSet?.follows?.invalidateData() - - // Update Followers of the past user list - // Update Followers of the new contact list - (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { - LocalCache - .getUserIfExists(it) - ?.flowSet - ?.followers - ?.invalidateData() - } - (latestContactList)?.unverifiedFollowKeySet()?.forEach { - LocalCache - .getUserIfExists(it) - ?.flowSet - ?.followers - ?.invalidateData() - } - } - - fun addZap( - zapRequest: Note, - zap: Note?, - ) { - if (zaps[zapRequest] == null) { - zaps = zaps + Pair(zapRequest, zap) - flowSet?.zaps?.invalidateData() - } - } - - fun removeZap(zapRequestOrZapEvent: Note) { - if (zaps.containsKey(zapRequestOrZapEvent)) { - zaps = zaps.minus(zapRequestOrZapEvent) - flowSet?.zaps?.invalidateData() - } else if (zaps.containsValue(zapRequestOrZapEvent)) { - zaps = zaps.filter { it.value != zapRequestOrZapEvent } - flowSet?.zaps?.invalidateData() - } - } - - fun zappedAmount(): BigDecimal { - var amount = BigDecimal.ZERO - zaps.forEach { - val itemValue = (it.value?.event as? LnZapEvent)?.amount - if (itemValue != null) { - amount += itemValue - } - } - - return amount - } - - fun addRelayBeingUsed( - relay: NormalizedRelayUrl, - eventTime: Long, - ) { - val here = relaysBeingUsed[relay] - if (here == null) { - relaysBeingUsed = relaysBeingUsed + Pair(relay, RelayInfo(relay, eventTime, 1)) - } else { - if (eventTime > here.lastEvent) { - here.lastEvent = eventTime - } - here.counter++ - } - - flowSet?.usedRelays?.invalidateData() - } - - fun updateUserInfo( - newUserInfo: UserMetadata, - latestMetadata: MetadataEvent, - ) { - info = newUserInfo - info?.tags = latestMetadata.tags.toImmutableListOfLists() - info?.cleanBlankNames() - - if (newUserInfo.lud16.isNullOrBlank()) { - info?.lud06?.let { - if (it.lowercase().startsWith("lnurl")) { - info?.lud16 = Lud06().toLud16(it) - } - } - } - - flowSet?.metadata?.invalidateData() - } - - fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false - - fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size - - fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false } - - fun reportsOrNull(): UserReportCache? = reports - - fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it } - - // fun reportsOrNull(): UserReports? = deps[UserReports::class] as? UserReports - - // fun reports(): UserReports = deps.getOrPut(UserReports::class) { UserReports() } as UserReports - - fun cardsOrNull(): UserCardsCache? = cards - - fun cards(): UserCardsCache = cards ?: UserCardsCache().also { cards = it } - - fun containsAny(hiddenWordsCase: List): Boolean { - if (hiddenWordsCase.isEmpty()) return false - - if (toBestDisplayName().containsAny(hiddenWordsCase)) { - return true - } - - if (profilePicture()?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info?.banner?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info?.about?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info?.lud06?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info?.lud16?.containsAny(hiddenWordsCase) == true) { - return true - } - - if (info?.nip05?.containsAny(hiddenWordsCase) == true) { - return true - } - - return false - } - - fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false - - @Synchronized - fun createOrDestroyFlowSync(create: Boolean) { - if (create) { - if (flowSet == null) { - flowSet = UserFlowSet(this) - } - } else { - if (flowSet != null && flowSet?.isInUse() == false) { - flowSet = null - } - } - } - - fun flow(): UserFlowSet { - if (flowSet == null) { - createOrDestroyFlowSync(true) - } - return flowSet!! - } - - fun clearFlow() { - if (flowSet != null && flowSet?.isInUse() == false) { - createOrDestroyFlowSync(false) - } - } -} - -@Stable -class UserFlowSet( - u: User, -) { - // Observers line up here. - val metadata = UserBundledRefresherFlow(u) - val follows = UserBundledRefresherFlow(u) - val followers = UserBundledRefresherFlow(u) - val usedRelays = UserBundledRefresherFlow(u) - val zaps = UserBundledRefresherFlow(u) - val statuses = UserBundledRefresherFlow(u) - - fun isInUse(): Boolean = - metadata.hasObservers() || - follows.hasObservers() || - followers.hasObservers() || - usedRelays.hasObservers() || - zaps.hasObservers() || - statuses.hasObservers() -} - -@Immutable -data class RelayInfo( - val url: NormalizedRelayUrl, - var lastEvent: Long, - var counter: Long, -) - -@Stable -class UserBundledRefresherFlow( - val user: User, -) { - val stateFlow = MutableStateFlow(UserState(user)) - - fun invalidateData() { - stateFlow.tryEmit(UserState(user)) - } - - fun hasObservers() = stateFlow.subscriptionCount.value > 0 -} - -@Immutable -class UserState( - val user: User, -) - -fun Set.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } - -fun Set.toSortedHexes() = map { it.pubkeyHex }.sorted() - -fun List.toHexes() = map { it.pubkeyHex } - -fun List.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } - -fun List.toSortedHexes() = map { it.pubkeyHex }.sorted() +// Re-export from commons for backwards compatibility +typealias UserDependencies = com.vitorpamplona.amethyst.commons.model.UserDependencies +typealias User = com.vitorpamplona.amethyst.commons.model.User +typealias UserFlowSet = com.vitorpamplona.amethyst.commons.model.UserFlowSet +typealias RelayInfo = com.vitorpamplona.amethyst.commons.model.RelayInfo +typealias UserBundledRefresherFlow = com.vitorpamplona.amethyst.commons.model.UserBundledRefresherFlow +typealias UserState = com.vitorpamplona.amethyst.commons.model.UserState 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 index d64ec40bd..503eb1c4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -50,6 +50,7 @@ class UserMetadataState( suspend fun sendNewUserMetadata( name: String? = null, + displayName: String? = null, picture: String? = null, banner: String? = null, website: String? = null, @@ -69,7 +70,7 @@ class UserMetadataState( MetadataEvent.updateFromPast( latest = latest, name = name, - displayName = name, + displayName = displayName, picture = picture, banner = banner, website = website, @@ -85,7 +86,7 @@ class UserMetadataState( } else { MetadataEvent.createNew( name = name, - displayName = name, + displayName = displayName, picture = picture, banner = banner, website = website, 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 deleted file mode 100644 index bf580d23c..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.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/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index 8b5990b65..2230daca6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -20,6 +20,7 @@ */ 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 @@ -66,7 +67,7 @@ class NwcSignerState( val cache: LocalCache, val scope: CoroutineScope, val settings: AccountSettings, -) { +) : INwcSignerState { /** * Derives a NIP-47 signer from the zap payment request in settings. * If there's no valid configuration, it defaults to the main signer. @@ -112,7 +113,7 @@ class NwcSignerState( fun hasWalletConnectSetup(): Boolean = settings.zapPaymentRequest.value != null - fun isNIP47Author(pubkeyHex: String?): Boolean = nip47Signer.value.pubKey == pubkeyHex + override fun isNIP47Author(pubkeyHex: String?): Boolean = nip47Signer.value.pubKey == pubkeyHex /** * Decrypts a NIP-47 payment request using the current signer. @@ -120,7 +121,7 @@ class NwcSignerState( * @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? { + override suspend fun decryptRequest(nwcRequest: LnZapPaymentRequestEvent): Request? { if (!hasWalletConnectSetup()) return null return zapPaymentRequestDecryptionCache.value.decryptRequest(nwcRequest) } @@ -131,7 +132,7 @@ class NwcSignerState( * @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? { + override suspend fun decryptResponse(nwsResponse: LnZapPaymentResponseEvent): Response? { if (!hasWalletConnectSetup()) return null return zapPaymentResponseDecryptionCache.value.decryptResponse(nwsResponse) } 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 index 6606229ec..e97ab1585 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -41,6 +42,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +@Stable class BookmarkListState( val signer: NostrSigner, val cache: LocalCache, @@ -274,4 +276,26 @@ class BookmarkListState( null } } + + suspend fun removeBookmark(note: Note): BookmarkListEvent? { + val bookmarkList = getBookmarkList() + + return if (bookmarkList != null) { + if (note is AddressableNote) { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + signer = signer, + ) + } else { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + 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 index b20482350..80e618b76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists -import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -47,33 +47,25 @@ class HiddenUsersState( ) { 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, + ): LiveHiddenUsers { + val hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null } + val hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null } + + return LiveHiddenUsers( showSensitiveContent = showSensitiveContent, + hiddenWordsCase = hiddenWords.map { DualCase(it.lowercase(), it.uppercase()) }, + hiddenUsersHashCodes = hiddenUsers.mapTo(HashSet()) { it.hashCode() }, + spammersHashCodes = transientHiddenUsers.mapTo(HashSet()) { it.hashCode() }, + hiddenUsers = hiddenUsers, + spammers = transientHiddenUsers, + hiddenWords = hiddenWords, ) + } val flow: StateFlow = combineTransform( @@ -97,7 +89,15 @@ class HiddenUsersState( .stateIn( scope, SharingStarted.Eagerly, - LiveHiddenUsers(emptySet(), emptySet(), emptySet(), null), + LiveHiddenUsers( + showSensitiveContent = null, + hiddenWordsCase = emptyList(), + hiddenUsersHashCodes = emptySet(), + spammersHashCodes = emptySet(), + hiddenUsers = emptySet(), + spammers = emptySet(), + hiddenWords = emptySet(), + ), ) fun resetTransientUsers() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index b8686c695..55f02b4e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists +import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent +import com.vitorpamplona.amethyst.commons.model.eventIdSet +import com.vitorpamplona.amethyst.commons.model.events +import com.vitorpamplona.amethyst.commons.model.updateFlow 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.anyNotNullEvent -import com.vitorpamplona.amethyst.model.eventIdSet -import com.vitorpamplona.amethyst.model.events import com.vitorpamplona.amethyst.model.filter -import com.vitorpamplona.amethyst.model.updateFlow import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 59ab65117..16abb8276 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -20,16 +20,16 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists.peopleList +import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent +import com.vitorpamplona.amethyst.commons.model.eventIdSet +import com.vitorpamplona.amethyst.commons.model.events +import com.vitorpamplona.amethyst.commons.model.updateFlow 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.model.anyNotNullEvent -import com.vitorpamplona.amethyst.model.eventIdSet -import com.vitorpamplona.amethyst.model.events import com.vitorpamplona.amethyst.model.filter -import com.vitorpamplona.amethyst.model.updateFlow import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt index 77e6d0a54..9f8076c60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -20,16 +20,16 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists.peopleList +import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent +import com.vitorpamplona.amethyst.commons.model.eventIdSet +import com.vitorpamplona.amethyst.commons.model.events +import com.vitorpamplona.amethyst.commons.model.updateFlow 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.model.anyNotNullEvent -import com.vitorpamplona.amethyst.model.eventIdSet -import com.vitorpamplona.amethyst.model.events import com.vitorpamplona.amethyst.model.filter -import com.vitorpamplona.amethyst.model.updateFlow import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt index 9219c2b92..c49b79eab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privateChats/Chatroom.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.amethyst.model.privateChats import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.ListChange 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.amethyst.ui.dal.ListChange import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt index 758747bb9..67c0ebd29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/TrustProviderListState.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.model.trustedAssertions.TrustProviderListState as ITrustProviderListState class TrustProviderListState( val signer: NostrSigner, @@ -49,7 +50,7 @@ class TrustProviderListState( val decryptionCache: TrustProviderListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, -) { +) : ITrustProviderListState { // Creates a long-term reference for this note so that the GC doesn't collect the note it self val trustProviderListNote = cache.getOrCreateAddressableNote(getTrustProviderListAddress()) @@ -79,7 +80,7 @@ class TrustProviderListState( ) @OptIn(ExperimentalCoroutinesApi::class) - val liveUserRankProvider: StateFlow = + override val liveUserRankProvider: StateFlow = liveTrustProviderList .map { it.firstOrNull { it.service == ProviderTypes.rank } @@ -97,7 +98,7 @@ class TrustProviderListState( ) @OptIn(ExperimentalCoroutinesApi::class) - val liveUserFollowerCount: StateFlow = + override val liveUserFollowerCount: StateFlow = liveTrustProviderList .map { tagList -> tagList.firstOrNull { it.service == ProviderTypes.followerCount } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/BundledUpdate.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BundledUpdate.kt similarity index 97% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/BundledUpdate.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/BundledUpdate.kt index 037a603db..2c5db2a8d 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/BundledUpdate.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/BundledUpdate.kt @@ -18,9 +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.ammolite.relays +package com.vitorpamplona.amethyst.service -import com.vitorpamplona.ammolite.service.checkNotInMainThread import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler @@ -135,8 +134,6 @@ class BasicBundledInsert( newObject: T, onUpdate: suspend (Set) -> Unit, ) { - checkNotInMainThread() - queue.put(newObject) if (onlyOneInBlock.getAndSet(true)) { 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 35fb7b90d..5ad6597e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -28,15 +28,13 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.coroutines.executeAsync -class Nip05NostrAddressVerifier { +object Nip05NostrAddressVerifier { suspend fun fetchNip05Json( nip05: String, okHttpClient: (String) -> OkHttpClient, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) = withContext(Dispatchers.IO) { - checkNotInMainThread() - val url = Nip05().assembleUrl(nip05) if (url == null) { @@ -73,15 +71,10 @@ class Nip05NostrAddressVerifier { onSuccess: suspend (String) -> Unit, onError: (String) -> Unit, ) { - // check fails on tests - checkNotInMainThread() - fetchNip05Json( nip05, okHttpClient, onSuccess = { - checkNotInMainThread() - Nip05().parseHexKeyFor(nip05, it.lowercase()).fold( onSuccess = { hexKey -> if (hexKey == null) { 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 3175a2824..be4565568 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 @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.service.logging import android.os.Build -import android.os.Looper import android.os.StrictMode import android.os.StrictMode.ThreadPolicy import android.os.StrictMode.VmPolicy @@ -59,8 +58,8 @@ class Logging { }.penaltyLog() .build(), ) - Looper.getMainLooper().setMessageLogging(LogMonitor()) - ChoreographerHelper.start() + // Looper.getMainLooper().setMessageLogging(LogMonitor()) + // ChoreographerHelper.start() // Enable recomposition tracking ONLY in debug builds ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) 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 3ec0bd726..e5db2d6e0 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 @@ -197,8 +197,6 @@ class MediaSessionPool( controller: MediaSession.ControllerInfo, mediaItems: List, ): ListenableFuture> { - mediaSession.player.setMediaItems(mediaItems) - // set up return call when clicking on the Notification bar mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString("callbackUri")?.let { mediaSession.setSessionActivity( 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 9f77cd2f2..3cc0b10e4 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 @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.service.previews +import com.vitorpamplona.amethyst.commons.preview.HtmlCharsetParser import com.vitorpamplona.amethyst.commons.preview.MetaTag import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.MediaType import okio.BufferedSource import okio.ByteString.Companion.decodeHex import okio.Options @@ -32,10 +32,6 @@ import java.nio.charset.Charset class HtmlParser { companion object { - val ATTRIBUTE_VALUE_CHARSET = "charset" - val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv" - val CONTENT = "content" - // taken from okhttp private val UNICODE_BOMS = Options.of( @@ -50,25 +46,30 @@ class HtmlParser { // UTF-32LE "ffff0000".decodeHex(), ) - - private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""") } suspend fun parseHtml( source: BufferedSource, - type: MediaType, + type: Charset?, + ): Sequence = + parseHtml( + source.readByteArray(), + type ?: source.readBomAsCharset(), + ) + + suspend fun parseHtml( + bodyBytes: ByteArray, + type: Charset?, ): Sequence = withContext(Dispatchers.IO) { // sniff charset from Content-Type header or BOM - val sniffedCharset = type.charset() ?: source.readBomAsCharset() - if (sniffedCharset != null) { - val content = source.readByteArray().toString(sniffedCharset) + if (type != null) { + val content = bodyBytes.toString(type) return@withContext MetaTagsParser.parse(content) } // if sniffing was failed, detect charset from content - val bodyBytes = source.readByteArray() - val charset = detectCharset(bodyBytes) + val charset = HtmlCharsetParser.detectCharset(bodyBytes) val content = bodyBytes.toString(charset) return@withContext MetaTagsParser.parse(content) } @@ -83,29 +84,4 @@ class HtmlParser { -1 -> null else -> throw AssertionError() } - - private fun detectCharset(bodyBytes: ByteArray): Charset { - // try to detect charset from meta tags parsed from first 1024 bytes of body - val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8")) - val metaTags = MetaTagsParser.parse(firstPart) - metaTags.forEach { meta -> - val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET) - if (charsetAttr.isNotEmpty()) { - runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let { - return it - } - } - if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") { - RE_CONTENT_TYPE_CHARSET - .find(meta.attr(CONTENT)) - ?.let { - runCatching { Charset.forName(it.groupValues[1]) }.getOrNull() - }?.let { - return it - } - } - } - // defaults to UTF-8 - return Charset.forName("utf-8") - } } 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 3d25f3886..7eb4e9f75 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 @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.previews +import com.vitorpamplona.amethyst.commons.preview.OpenGraphParser +import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -62,7 +64,7 @@ class UrlPreview { 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 metaTags = HtmlParser().parseHtml(response.body.source(), mimeType.charset()) val data = OpenGraphParser().extractUrlInfo(metaTags) UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString()) } else if (mimeType.type == "image") { 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 index a5ba24b91..970e79779 100644 --- 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 @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers import com.vitorpamplona.amethyst.isDebug -import com.vitorpamplona.ammolite.relays.BundledUpdate +import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index dbd28b2dd..c455d77e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -47,7 +47,10 @@ fun DisplayNotifyMessages( val flow = remember(accountViewModel) { requests.transientPaymentRequests.map { - it.filter { it.relayUrl in accountViewModel.account.trustedRelays.flow.value } + it.filter { notifyMsg -> + notifyMsg.relayUrl in accountViewModel.account.dmRelayList.flow.value || + notifyMsg.relayUrl in accountViewModel.account.nip65RelayList.allFlowNoDefaults.value + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 9c7db9910..4693c9e74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -24,10 +24,10 @@ import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.IEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast -import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker 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 index edf756ce4..ff3f946a6 100644 --- 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 @@ -25,8 +25,8 @@ 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.MutableTime import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.ammolite.relays.filters.MutableTime import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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 index d40d4fb69..d2d2b59da 100644 --- 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 @@ -249,15 +249,12 @@ fun observeUserFollowCount( remember(user) { user .flow() - .followers.stateFlow - .sample(200) - .mapLatest { userState -> - userState.user.transientFollowCount() ?: 0 - }.distinctUntilChanged() + .follows.stateFlow + .mapLatest { it.user.transientFollowCount() ?: 0 } .flowOn(Dispatchers.IO) } - return flow.collectAsStateWithLifecycle(0) + return flow.collectAsStateWithLifecycle(user.transientFollowCount() ?: 0) } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index f8fbd4542..a8bdc42cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +import com.vitorpamplona.amethyst.commons.model.toHexSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.toHexSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.amethyst.service.relays.MutableTime import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.ammolite.relays.filters.MutableTime import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter 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 index f4342fdaf..2b4843562 100644 --- 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 @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers +import com.vitorpamplona.amethyst.commons.model.toHexSet import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.toHexSet import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState +import com.vitorpamplona.amethyst.service.relays.MutableTime import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.ammolite.relays.filters.MutableTime import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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 index dd1cf3e62..3c69f4fad 100644 --- 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 @@ -24,6 +24,7 @@ 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.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent @@ -81,6 +82,7 @@ val SearchPostsByTextKinds3 = InteractiveStoryPrologueEvent.KIND, InteractiveStorySceneEvent.KIND, FollowListEvent.KIND, + NipTextEvent.KIND, ) fun searchPostsByText( 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 0d1d6bbe8..581e3e099 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 @@ -22,37 +22,12 @@ package com.vitorpamplona.amethyst.service.relays import androidx.collection.LruCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.ammolite.relays.filters.MutableTime import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -typealias SincePerRelayMap = MutableMap - -class EOSERelayList { - var relayList: SincePerRelayMap = mutableMapOf() - - fun addOrUpdate( - relayUrl: NormalizedRelayUrl, - time: Long, - ) { - val eose = relayList[relayUrl] - if (eose == null) { - relayList[relayUrl] = MutableTime(time) - } else { - eose.updateIfNewer(time) - } - } - - fun clear() { - relayList = mutableMapOf() - } - - fun since() = relayList - - fun newEose( - relay: NormalizedRelayUrl, - time: Long, - ) = addOrUpdate(relay, time) -} +// Re-export from commons for backwards compatibility +typealias EOSERelayList = com.vitorpamplona.amethyst.commons.relays.EOSERelayList +typealias SincePerRelayMap = com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +typealias MutableTime = com.vitorpamplona.amethyst.commons.relays.MutableTime open class EOSEByKey( cacheSize: Int = 200, 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 0fa2fd845..14c841ad2 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 @@ -70,11 +70,12 @@ fun NewUserMetadataScreen( SavingTopBar( titleRes = R.string.profile, onCancel = { - postViewModel.clear() nav.popBack() }, onPost = { - postViewModel.create() + accountViewModel.launchSigner { + postViewModel.create() + } nav.popBack() }, ) @@ -95,7 +96,27 @@ fun NewUserMetadataScreen( modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), ) { OutlinedTextField( - label = { Text(text = stringRes(R.string.profile_name)) }, + label = { Text(text = stringRes(R.string.profile_name_with_explainer)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.name.value, + onValueChange = { postViewModel.name.value = it }, + placeholder = { + Text( + text = stringRes(R.string.my_name), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.display_name)) }, modifier = Modifier.fillMaxWidth(), value = postViewModel.displayName.value, onValueChange = { postViewModel.displayName.value = it }, 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 139bf9587..32dddea67 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 @@ -47,7 +47,7 @@ class NewUserMetadataViewModel : ViewModel() { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account - // val userName = mutableStateOf("") + val name = mutableStateOf("") val displayName = mutableStateOf("") val about = mutableStateOf("") @@ -74,8 +74,8 @@ class NewUserMetadataViewModel : ViewModel() { fun load() { account.userProfile().let { - // userName.value = it.bestUsername() ?: "" - displayName.value = it.info?.bestName() ?: "" + name.value = it.info?.name ?: "" + displayName.value = it.info?.displayName ?: "" about.value = it.info?.about ?: "" picture.value = it.info?.picture ?: "" banner.value = it.info?.banner ?: "" @@ -100,33 +100,31 @@ class NewUserMetadataViewModel : ViewModel() { } } - fun create() { - // Tries to not delete any existing attribute that we do not work with. - accountViewModel.launchSigner { - 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, - ) + suspend fun create() { + val metadata = + account.userMetadata.sendNewUserMetadata( + name = name.value, + displayName = 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) + account.sendLiterallyEverywhere(metadata) - clear() - } + clear() } fun clear() { - // userName.value = "" + name.value = "" displayName.value = "" about.value = "" picture.value = "" 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 index c5083fb12..32af08e0b 100644 --- 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 @@ -37,7 +37,7 @@ 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.components.ToggleableBox import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -52,15 +52,16 @@ fun RecordAudioBox( val mediaRecorder = remember { mutableStateOf(null) } val context = LocalContext.current var elapsedSeconds by remember { mutableIntStateOf(0) } - var wantsToRecord by remember { mutableStateOf(false) } + var pendingPermissionStart by remember { mutableStateOf(false) } // Must be called at Composable scope, not in callback val recordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) val scope = rememberCoroutineScope() + val isRecording = mediaRecorder.value != null + DisposableEffect(Unit) { onDispose { - wantsToRecord = false mediaRecorder.value?.stop() mediaRecorder.value = null } @@ -74,8 +75,25 @@ fun RecordAudioBox( } } - LaunchedEffect(recordPermissionState.status.isGranted, wantsToRecord) { - if (recordPermissionState.status.isGranted && wantsToRecord) { + fun stopRecording() { + val result = mediaRecorder.value?.stop() + mediaRecorder.value = null + if (result != null) { + onRecordTaken(result) + } else { + Toast + .makeText( + context, + stringRes(context, R.string.record_a_message_description), + Toast.LENGTH_SHORT, + ).show() + } + } + + // Start recording after permission is granted + LaunchedEffect(recordPermissionState.status.isGranted) { + if (recordPermissionState.status.isGranted && pendingPermissionStart) { + pendingPermissionStart = false startRecording() } } @@ -96,38 +114,21 @@ fun RecordAudioBox( } } - ClickAndHoldBoxComposable( + ToggleableBox( modifier = modifier, - onPress = { - wantsToRecord = true - if (!recordPermissionState.status.isGranted) { - recordPermissionState.launchPermissionRequest() + isActive = isRecording, + onClick = { + if (isRecording) { + stopRecording() } else { - // Start immediately for responsive UX when permission already granted - startRecording() + if (!recordPermissionState.status.isGranted) { + pendingPermissionStart = true + recordPermissionState.launchPermissionRequest() + } else { + startRecording() + } } }, - onRelease = { - wantsToRecord = false - val result = mediaRecorder.value?.stop() - mediaRecorder.value = null - 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 = { - wantsToRecord = false - mediaRecorder.value?.stop() - mediaRecorder.value = null - }, - content = @Composable { isRecording -> content(isRecording, elapsedSeconds) }, + content = { active -> content(active, elapsedSeconds) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index a4e3ecd65..0f7fb0946 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -35,7 +35,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -206,8 +206,8 @@ fun FloatingRecordingIndicator( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = innerPadding), ) { - // Pulsing red dot - val infiniteTransition = rememberInfiniteTransition(label = "recording_dot") + // Pulsing stop square + val infiniteTransition = rememberInfiniteTransition(label = "recording_stop") val dotAlpha by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 0.5f, @@ -220,7 +220,7 @@ fun FloatingRecordingIndicator( ) Icon( - imageVector = Icons.Default.FiberManualRecord, + imageVector = Icons.Default.Stop, contentDescription = recordingLabel, tint = Color.White, modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index a601837be..7633a47d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -74,64 +74,23 @@ fun VoiceMessagePreview( var progress by remember { mutableFloatStateOf(0f) } var mediaPlayer by remember { mutableStateOf(null) } - // Initialize MediaPlayer - DisposableEffect(voiceMetadata.url, localFile) { - val player = createMediaPlayer(voiceMetadata.url, localFile) - player?.setOnCompletionListener { + ManageMediaPlayer( + voiceMetadata = voiceMetadata, + localFile = localFile, + onCompletion = { isPlaying = false progress = 0f - } - mediaPlayer = player + }, + onPlayerChanged = { mediaPlayer = it }, + onRelease = { isPlaying = false }, + ) - onDispose { - // Stop playback and clean up - try { - player?.stop() - } catch (e: IllegalStateException) { - // Player might already be stopped - Log.d("VoiceMessagePreview", "MediaPlayer stop failed (already stopped)", e) - } - player?.release() - mediaPlayer = null - isPlaying = false - } - } - - // Update progress while playing - LaunchedEffect(mediaPlayer, isPlaying) { - // Capture player reference to avoid reading volatile state repeatedly - val player = mediaPlayer - if (player != null && isPlaying) { - while (isActive) { - try { - if (player.isPlaying) { - val current = player.currentPosition.toFloat() - val duration = player.duration.toFloat() - // Validate values before calculating progress - val newProgress = - if (duration > 0 && current >= 0) { - (current / duration).coerceIn(0f, 1f) - } else { - 0f - } - // Only update if value is valid (not NaN or Infinity) - if (newProgress.isFinite()) { - progress = newProgress - } - } else { - // Player stopped, exit loop and let LaunchedEffect restart - break - } - } catch (e: IllegalStateException) { - // Player in invalid state, stop tracking - Log.w("VoiceMessagePreview", "MediaPlayer in invalid state during progress tracking", e) - isPlaying = false - break - } - delay(100) - } - } - } + TrackPlaybackProgress( + mediaPlayer = mediaPlayer, + isPlaying = isPlaying, + onProgressUpdate = { progress = it }, + onInvalidState = { isPlaying = false }, + ) Box( modifier = @@ -150,27 +109,13 @@ fun VoiceMessagePreview( // Play/Pause Button IconButton( onClick = { - val player = mediaPlayer - if (player != null) { - try { - if (isPlaying) { - player.pause() - isPlaying = false - } else { - // Validate progress before comparison - if (progress.isFinite() && progress >= 1f) { - player.seekTo(0) - progress = 0f - } - player.start() - isPlaying = true - } - } catch (e: IllegalStateException) { - // MediaPlayer in invalid state, ignore - Log.w("VoiceMessagePreview", "MediaPlayer operation failed in onClick handler", e) - isPlaying = false - } - } + handlePlayPauseClick( + mediaPlayer = mediaPlayer, + isPlaying = isPlaying, + progress = progress, + onProgressReset = { progress = 0f }, + onPlayingChanged = { isPlaying = it }, + ) }, modifier = Modifier.size(48.dp), ) { @@ -194,24 +139,11 @@ fun VoiceMessagePreview( waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), onProgressChange = { newProgress -> - // Validate incoming progress value - if (newProgress.isFinite() && newProgress >= 0f && newProgress <= 1f) { - val player = mediaPlayer - if (player != null) { - try { - val duration = player.duration - // Only seek if duration is valid - if (duration > 0) { - val newPosition = (newProgress * duration).toInt() - player.seekTo(newPosition) - progress = newProgress - } - } catch (e: IllegalStateException) { - // MediaPlayer in invalid state, ignore - Log.w("VoiceMessagePreview", "MediaPlayer seek failed in onProgressChange", e) - } - } - } + handleWaveformScrub( + newProgress = newProgress, + mediaPlayer = mediaPlayer, + onProgressChanged = { progress = it }, + ) }, ) @@ -240,6 +172,115 @@ fun VoiceMessagePreview( } } +@Composable +private fun ManageMediaPlayer( + voiceMetadata: AudioMeta, + localFile: File?, + onCompletion: () -> Unit, + onPlayerChanged: (MediaPlayer?) -> Unit, + onRelease: () -> Unit, +) { + DisposableEffect(voiceMetadata.url, localFile) { + val player = createMediaPlayer(voiceMetadata.url, localFile) + player?.setOnCompletionListener { onCompletion() } + onPlayerChanged(player) + + onDispose { + try { + player?.stop() + } catch (e: IllegalStateException) { + Log.d("VoiceMessagePreview", "MediaPlayer stop failed (already stopped)", e) + } + player?.release() + onPlayerChanged(null) + onRelease() + } + } +} + +@Composable +private fun TrackPlaybackProgress( + mediaPlayer: MediaPlayer?, + isPlaying: Boolean, + onProgressUpdate: (Float) -> Unit, + onInvalidState: () -> Unit, +) { + LaunchedEffect(mediaPlayer, isPlaying) { + val player = mediaPlayer ?: return@LaunchedEffect + if (!isPlaying) return@LaunchedEffect + + while (isActive) { + try { + if (!player.isPlaying) break + calculateProgress(player.currentPosition.toFloat(), player.duration.toFloat())?.let { onProgressUpdate(it) } + } catch (e: IllegalStateException) { + Log.w("VoiceMessagePreview", "MediaPlayer in invalid state during progress tracking", e) + onInvalidState() + break + } + delay(100) + } + } +} + +private fun calculateProgress( + current: Float, + duration: Float, +): Float? { + val progress = + if (duration > 0 && current >= 0) { + (current / duration).coerceIn(0f, 1f) + } else { + 0f + } + return progress.takeIf { it.isFinite() } +} + +private fun handlePlayPauseClick( + mediaPlayer: MediaPlayer?, + isPlaying: Boolean, + progress: Float, + onProgressReset: () -> Unit, + onPlayingChanged: (Boolean) -> Unit, +) { + val player = mediaPlayer ?: return + try { + if (isPlaying) { + player.pause() + onPlayingChanged(false) + return + } + if (progress.isFinite() && progress >= 1f) { + player.seekTo(0) + onProgressReset() + } + player.start() + onPlayingChanged(true) + } catch (e: IllegalStateException) { + Log.w("VoiceMessagePreview", "MediaPlayer operation failed in onClick handler", e) + onPlayingChanged(false) + } +} + +private fun handleWaveformScrub( + newProgress: Float, + mediaPlayer: MediaPlayer?, + onProgressChanged: (Float) -> Unit, +) { + if (!newProgress.isFinite() || newProgress < 0f || newProgress > 1f) return + val player = mediaPlayer ?: return + try { + val duration = player.duration + if (duration > 0) { + val newPosition = (newProgress * duration).toInt() + player.seekTo(newPosition) + onProgressChanged(newProgress) + } + } catch (e: IllegalStateException) { + Log.w("VoiceMessagePreview", "MediaPlayer seek failed in onProgressChange", e) + } +} + private fun createMediaPlayer( url: String, localFile: File?, 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 b6453b4cf..490352912 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 @@ -26,7 +26,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxWidth 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 @@ -71,7 +70,6 @@ fun AudioWaveformReadOnly( progressBrush: Brush = SolidColor(Color.Blue), waveformAlignment: WaveformAlignment = WaveformAlignment.Center, amplitudeType: AmplitudeType = AmplitudeType.Avg, - onProgressChangeFinished: (() -> Unit)? = null, spikeAnimationSpec: AnimationSpec = tween(500), spikeWidth: Dp = 3.dp, spikeRadius: Dp = 2.dp, @@ -80,7 +78,6 @@ fun AudioWaveformReadOnly( amplitudes: List, onProgressChange: (Float) -> Unit, ) { - val backgroundColor = MaterialTheme.colorScheme.background val progressState = remember(progress) { progress.coerceIn(MIN_PROGRESS, MAX_PROGRESS) } val spikeWidthState = remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) } @@ -195,7 +192,20 @@ internal fun Iterable.chunkToSize( internal fun Iterable.normalize( min: Float, max: Float, -): List = map { (max - min) * ((it - min()) / (max() - min())) + min } +): List { + val values = toList() + if (values.isEmpty()) return emptyList() + + val currentMin = values.minOrNull() ?: return emptyList() + val currentMax = values.maxOrNull() ?: return emptyList() + val range = currentMax - currentMin + if (!range.isFinite() || range == 0f) { + return List(values.size) { min } + } + + val scale = max - min + return values.map { scale * ((it - currentMin) / range) + min } +} private fun Int.safeDiv(value: Int): Float { return if (value == 0) return 0F else this / value.toFloat() 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 6df79b5cd..a63656d4b 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 @@ -28,17 +28,12 @@ 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 @@ -64,116 +59,6 @@ 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: () -> Unit, - onRelease: suspend () -> Unit, - onCancel: suspend () -> Unit, - content: @Composable (Boolean) -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - var isPressed by remember { mutableStateOf(false) } - - LaunchedEffect(interactionSource) { - val pressInteractions = mutableListOf() - interactionSource.interactions.collect { interaction -> - when (interaction) { - is PressInteraction.Press -> { - if (pressInteractions.isEmpty()) { - onPress() - } - 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( @@ -195,3 +80,38 @@ fun ClickableBox( content() } } + +@Composable +fun ToggleableBox( + modifier: Modifier = Modifier, + isActive: Boolean, + onClick: () -> Unit, + content: @Composable (Boolean) -> Unit, +) { + // Animation for the button scale + val scale by animateFloatAsState( + targetValue = if (isActive) 1.5f else 1.0f, + animationSpec = tween(durationMillis = 150), + ) + + // Animation for the button color + val backgroundColor by animateColorAsState( + targetValue = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.background, + animationSpec = tween(durationMillis = 150), + ) + + Box( + modifier + .scale(scale) + .background(backgroundColor, CircleShape) + .clickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple24dp, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + content(isActive) + } +} 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 a006bd5ab..49a144591 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 @@ -40,7 +40,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.previews.UrlInfoItem +import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding 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 f8784de78..56608fb9f 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 @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.runtime.Immutable -import com.vitorpamplona.amethyst.service.previews.UrlInfoItem +import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem @Immutable sealed class UrlPreviewState { 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 223431553..9f20f4687 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 @@ -39,9 +39,9 @@ import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.ui.material3.RichText +import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem 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.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt index 792ef828d..dab6bd150 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt @@ -20,26 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.dal +import com.vitorpamplona.amethyst.model.ListChange import kotlinx.coroutines.flow.MutableSharedFlow interface ChangesFlowFilter : IAdditiveFeedFilter { fun changesFlow(): MutableSharedFlow> } - -sealed class ListChange { - data class Addition( - val item: T, - ) : ListChange() - - data class Deletion( - val item: T, - ) : ListChange() - - data class SetAddition( - val item: Set, - ) : ListChange() - - data class SetDeletion( - val item: Set, - ) : ListChange() -} 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 d7b26ea33..88ebbd8b4 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 @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.dal -import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils class FilterByListParams( val isHiddenList: Boolean, val followLists: IFeedTopNavFilter?, - val hiddenLists: HiddenUsersState.LiveHiddenUsers, + val hiddenLists: LiveHiddenUsers, val now: Long = TimeUtils.oneMinuteFromNow(), ) { fun isNotHidden(userHex: String) = !(hiddenLists.hiddenUsers.contains(userHex) || hiddenLists.spammers.contains(userHex)) @@ -87,7 +87,7 @@ class FilterByListParams( fun create( followLists: IFeedTopNavFilter?, - hiddenUsers: HiddenUsersState.LiveHiddenUsers, + hiddenUsers: LiveHiddenUsers, ): FilterByListParams = FilterByListParams( isHiddenList = followLists is MutedAuthorsByOutboxTopNavFilter || followLists is MutedAuthorsByProxyTopNavFilter, 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 index 8f7cedc71..cf62c2cf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt @@ -25,11 +25,11 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.BundledInsert +import com.vitorpamplona.amethyst.service.BundledUpdate 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 com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.collections.immutable.ImmutableList 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 b1a12702a..283735f7a 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 @@ -25,12 +25,12 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.BasicBundledInsert +import com.vitorpamplona.amethyst.service.BasicBundledUpdate import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.IFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists -import com.vitorpamplona.ammolite.relays.BasicBundledInsert -import com.vitorpamplona.ammolite.relays.BasicBundledUpdate import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.collections.immutable.ImmutableList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 9cd3b289d..9525b7ef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -34,8 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.myapplication.DisappearingBottomBar import com.vitorpamplona.myapplication.DisappearingFloatingButton -import com.vitorpamplona.myapplication.DisappearingTopBar -import com.vitorpamplona.myapplication.enterAlwaysScrollBehavior @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt index 26c9a82be..d03aca957 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt @@ -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.myapplication +package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationState @@ -58,7 +58,7 @@ fun DisappearingTopBar( scrollBehavior: CustomEnterAlwaysScrollBehavior, content: @Composable (ColumnScope.() -> Unit), ) { - ResetDisappearingOnResume(scrollBehavior) + // ResetDisappearingOnResume(scrollBehavior) // Set up support for resizing the top app bar when vertically dragging the bar itself. val appBarDragModifier = 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 30d7e9a14..9266e299e 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 @@ -58,12 +58,12 @@ import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index fb16001a6..3ae138470 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -46,7 +46,6 @@ 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.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts @@ -458,14 +457,6 @@ fun ListContent( NavigationRow( title = R.string.bookmarks, - icon = Icons.Outlined.BookmarkBorder, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Bookmarks, - ) - - NavigationRow( - title = R.string.bookmark_lists, icon = Icons.Outlined.CollectionsBookmark, tint = MaterialTheme.colorScheme.onBackground, nav = nav, 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 6d152f6ca..aa5928c7f 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 @@ -65,6 +65,7 @@ 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.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo 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 @@ -117,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status +import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -162,15 +164,15 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent -import com.vitorpamplona.quartz.experimental.forks.isAFork +import com.vitorpamplona.quartz.experimental.forks.IForkableEvent 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.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent 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 import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -225,6 +227,7 @@ 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.Dispatchers import kotlinx.coroutines.delay @Composable @@ -499,7 +502,16 @@ fun ClickableNote( } else { baseNote } - routeFor(redirectToNote, accountViewModel.account)?.let { nav.nav(it) } + + nav.nav { + if (redirectToNote.event is DraftWrapEvent) { + with(Dispatchers.IO) { + routeEditDraftTo(redirectToNote, accountViewModel.account) + } + } else { + routeFor(redirectToNote, accountViewModel.account) + } + } }, onLongClick = showPopup, ).background(backgroundColor.value) @@ -716,6 +728,7 @@ private fun RenderNoteRow( is ReportEvent -> RenderReport(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) is LongTextNoteEvent -> RenderLongFormContent(baseNote, accountViewModel, nav) is WikiNoteEvent -> RenderWikiContent(baseNote, accountViewModel, nav) + is NipTextEvent -> RenderNipContent(baseNote, accountViewModel, nav) is BadgeAwardEvent -> RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) is FhirResourceEvent -> RenderFhirResource(baseNote, accountViewModel, nav) is PeopleListEvent -> DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) @@ -1074,8 +1087,8 @@ fun SecondUserInfoRow( modifier = UserNameMaxRowHeight, ) { Column(modifier = remember(noteEvent) { Modifier.weight(1f) }) { - if (noteEvent is BaseThreadedEvent && noteEvent.isAFork()) { - ShowForkInformation(noteEvent, remember(noteEvent) { Modifier.weight(1f) }, accountViewModel, nav) + if (noteEvent is IForkableEvent && noteEvent.isAFork()) { + ShowForkInformation(noteEvent, Modifier, accountViewModel, nav) } else { ObserveDisplayNip05Status(noteAuthor, accountViewModel, 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 070403e87..c1bc1c0a1 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 @@ -116,6 +116,14 @@ val njumpLink = { nip19BechAddress: String -> "https://njump.to/$nip19BechAddress" } +val nipLink = { nipNumber: String -> + "https://nostrhub.io/$nipNumber" +} + +val graspLink = { graspNumber: String -> + "https://gitworkshop.dev/danconwaydev.com/grasp/tree/master/$graspNumber.md" +} + val externalLinkForNote = { note: Note -> if (note is AddressableNote) { if (note.event?.bountyBaseReward() != null) { 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 279a3414f..a2814a464 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 @@ -159,6 +159,7 @@ 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.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -1394,16 +1395,20 @@ private fun BoostTypeChoicePopup( Text(stringRes(R.string.quote), color = Color.White, textAlign = TextAlign.Center) } - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onFork, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(stringRes(R.string.fork), color = Color.White, textAlign = TextAlign.Center) + // removes the option to fork for now because we do not have screens for + // LongForm, Wiki and NIP posting. + if (baseNote.event is TextNoteEvent) { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onFork, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringRes(R.string.fork), color = Color.White, textAlign = TextAlign.Center) + } } } } 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 2b33c9b50..e6b09d2ca 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 @@ -56,6 +56,12 @@ fun showAmountInteger(amount: BigDecimal?): String { } } +fun showAmountInteger(amount: Int?): String { + if (amount == null) return "0" + + return showAmountIntegerWithZero(BigDecimal.valueOf(amount.toLong())) +} + fun showAmountIntegerWithZero(amount: BigDecimal?): String { if (amount == null) return "0" if (amount.abs() < BigDecimal(0.01)) return "0" 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 b8fa57650..b36093573 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 @@ -21,39 +21,29 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.material3.LocalTextStyle 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 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.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.Font14SP -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.nip05 -import com.vitorpamplona.quartz.experimental.forks.forkFromAddress -import com.vitorpamplona.quartz.experimental.forks.forkFromVersion -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.experimental.forks.IForkableEvent @Composable fun ShowForkInformation( - noteEvent: BaseThreadedEvent, + noteEvent: IForkableEvent, modifier: Modifier, accountViewModel: AccountViewModel, nav: INav, @@ -67,7 +57,7 @@ fun ShowForkInformation( } } } else if (forkedEvent != null) { - LoadNote(forkedEvent.eventId, accountViewModel) { event -> + LoadNote(forkedEvent, accountViewModel) { event -> if (event != null) { ForkInformationRowLightColor(event, modifier, accountViewModel, nav) } @@ -83,35 +73,19 @@ fun ForkInformationRowLightColor( nav: INav, ) { val noteState by observeNote(originalVersion, accountViewModel) - val note = noteState?.note ?: return + val note = noteState.note val author = note.author ?: return val route = remember(note) { routeFor(note, accountViewModel.account) } if (route != null) { - Row(modifier) { - Text( - text = - buildAnnotatedString { - appendLink(stringRes(id = R.string.forked_from) + " ") { - nav.nav(route) - } - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.nip05, - fontSize = Font14SP, - ), - maxLines = 1, - overflow = TextOverflow.Visible, - ) - + Row(modifier, verticalAlignment = Alignment.CenterVertically) { val userState by observeUser(author, accountViewModel) userState?.user?.toBestDisplayName()?.let { CreateClickableTextWithEmoji( - clickablePart = it, + clickablePart = stringRes(id = R.string.forked_from) + " " + it, maxLines = 1, route = route, - overrideColor = MaterialTheme.colorScheme.nip05, + overrideColor = MaterialTheme.colorScheme.primary, fontSize = Font14SP, nav = nav, tags = userState?.user?.info?.tags, @@ -120,35 +94,3 @@ fun ForkInformationRowLightColor( } } } - -@Composable -fun ForkInformationRow( - originalVersion: Note, - modifier: Modifier = Modifier, - accountViewModel: AccountViewModel, - nav: INav, -) { - val noteState by observeNote(originalVersion, accountViewModel) - val note = noteState?.note ?: return - val route = remember(note) { routeFor(note, accountViewModel.account) } - - if (route != null) { - Row(modifier) { - val author = note.author ?: return - val meta by observeUserInfo(author, accountViewModel) - - Text(stringRes(id = R.string.forked_from)) - Spacer(modifier = StdHorzSpacer) - - val userMetadata by observeUserInfo(author, accountViewModel) - - CreateClickableTextWithEmoji( - clickablePart = remember(meta) { meta?.bestName() ?: author.pubkeyDisplayHex() }, - maxLines = 1, - route = route, - nav = nav, - tags = userMetadata?.tags, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt new file mode 100644 index 000000000..557e26c46 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt @@ -0,0 +1,196 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, 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 android.R.attr.label +import android.R.attr.maxLines +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +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.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +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.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds + +@Composable +fun RenderNipContent( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? NipTextEvent ?: return + + NipNoteHeader(noteEvent, note, accountViewModel, nav) +} + +@Composable +private fun NipNoteHeader( + noteEvent: NipTextEvent, + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val title = remember(noteEvent) { noteEvent.title() } + val kinds = remember(noteEvent) { noteEvent.kinds() } + + Column( + modifier = + Modifier + .padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + verticalArrangement = SpacedBy5dp, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + Text( + text = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content }, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + if (kinds.isNotEmpty()) { + FlowRow( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + horizontalArrangement = SpacedBy5dp, + verticalArrangement = SpacedBy5dp, + itemVerticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.kinds), + ) + kinds.forEach { + NoPaddingSuggestionChip( + label = it.toString(), + ) + } + } + } + } +} + +@Preview +@Composable +fun NipNoteHeaderPreview() { + val event = + NipTextEvent( + id = "eb2b05394ff0014bb6a79c2eacfd1c80696821592f4dbf86c950c4bf16614aa0", + pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + createdAt = 1767978398, + content = "Trusted Translations\n--------------------\n\n`draft` `optional`\n\nThis NIP allows anyone to post a translation for any event on a `kind:76`.\n\n```js\n{\n \"kind\": 76,\n \"tags\": [\n [\"e\", \"\u003coriginal_event_id\u003e\", \"\u003crelay\u003e\"]\n [\"k\", \"\u003coriginal_event_kind\u003e\"]\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n [\"s\", \"title\", \"translated title tag\"]\n [\"s\", \"summary\", \"translated summary tag\"]\n ],\n \"content\": \"this is a translated version of the original content\",\n // ...other fields\n}\n```\n\n`e` tag points to the event being translated, `k` tag points to the kind of that event.\n\n`l` tags define the language this was translated to in lowercase codes as defined by ISO 639-1. \n\n`s` tags are the translations for tags in the original event. \n\n`.content` contains the translation of the original `.content`\n\nClients SHOULD request translations by `e` and `l` tags spoken by their user. For every tag being rendered, Clients SHOULD look for their translated versions.\n\nProviders SHOULD use the event id in the filter to know which events need translations.\n\nProviders MAY store their translations behind a paid relay with NIP-42 auth.\n\n## Declaring Translation Providers\n\nKind `10041` lists the user's authorized translation providers. Each `p` tag is followed by the `pubkey` of the service publishing kind 76s, and the relay translations can be found. Users can specify these publicly or privately by JSON-stringifying and encrypting the tag list in the `.content` using NIP-44. \n\n```js\n{\n \"kind\": 10041,\n \"tags\": [\n [\"p\", \"4fd5e210530e4f6b2cb083795834bfe5108324f1ed9f00ab73b9e8fcfe5f12fe\", \"wss://translations.nostr.com\"],\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n //...\n}\n```\n\n`l` tags in this event are the languages the user understands and wants translations to.\n\nProviders SHOULD create the `10041` event and post to the user's outbox relay.", + sig = "7fa9f1d49c41c7bfbdad6d089d1c6685777c86de8fbda7662a630888658839f0444cb1398385f759461c09191b287fa222eec0ffe22b3fa8cf740f71aab9dc21", + tags = + arrayOf( + arrayOf("d", "trusted-translations"), + arrayOf("title", "Trusted Translations"), + arrayOf("k", "1011"), + arrayOf("k", "10041"), + arrayOf("k", "1011"), + arrayOf("k", "10041"), + arrayOf("k", "1011"), + arrayOf("k", "10041"), + arrayOf("k", "1011"), + arrayOf("k", "10041"), + arrayOf("client", "nostrhub.io"), + ), + ) + + LocalCache.justConsume(event, null, true) + val note = LocalCache.getOrCreateNote(event.id) + + ThemeComparisonColumn( + toPreview = { + NipNoteHeader( + noteEvent = event, + note = note, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoPaddingSuggestionChip( + label: String, + modifier: Modifier = Modifier, +) { + Surface( + shape = MaterialTheme.shapes.extraSmall, // Use a small shape for chip look + color = MaterialTheme.colorScheme.secondaryContainer, // Default chip color + modifier = modifier, + ) { + Text( + text = label, + // Apply desired internal padding to the Text itself + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} 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 c96851b95..c969c26e4 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 @@ -214,7 +214,7 @@ class AccountStateViewModel : ViewModel() { loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError) } } else if (EMAIL_PATTERN.matcher(key).matches()) { - Nip05NostrAddressVerifier().verifyNip05( + Nip05NostrAddressVerifier.verifyNip05( key, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, onSuccess = { publicKey -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 6cf7aea7d..9ee0b0632 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.stringRes 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.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -392,6 +393,7 @@ val DEFAULT_FEED_KINDS = LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND, WikiNoteEvent.KIND, + NipTextEvent.KIND, InteractiveStoryPrologueEvent.KIND, ) @@ -406,6 +408,7 @@ val DEFAULT_COMMUNITY_FEEDS = AudioTrackEvent.KIND, PinListEvent.KIND, WikiNoteEvent.KIND, + NipTextEvent.KIND, CommunityPostApprovalEvent.KIND, InteractiveStoryPrologueEvent.KIND, ) 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 0ee61023e..b31b33843 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 @@ -27,11 +27,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists -import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList 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 8264d3cd5..9c983e475 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 @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -51,7 +52,6 @@ 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.model.privacyOptions.EmptyRoleBasedHttpClientBuilder @@ -341,7 +341,7 @@ class AccountViewModel( fun isNoteAcceptable( note: Note, - accountChoices: HiddenUsersState.LiveHiddenUsers, + accountChoices: LiveHiddenUsers, followUsers: Set, ): NoteComposeReportState { checkNotInMainThread() @@ -941,7 +941,7 @@ class AccountViewModel( val nip05 = userMetadata.nip05?.ifBlank { null } ?: return viewModelScope.launch(Dispatchers.IO) { - Nip05NostrAddressVerifier() + Nip05NostrAddressVerifier .verifyNip05( nip05, okHttpClient = httpClientBuilder::okHttpClientForNip05, 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/bookmarkgroups/default/BookmarkListScreen.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index ed958bead..111fc3876 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/bookmarkgroups/default/BookmarkListScreen.kt @@ -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.bookmarks +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column @@ -44,8 +44,8 @@ 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.bookmarks.dal.BookmarkPrivateFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPublicFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -93,7 +93,7 @@ private fun RenderBookmarkScreen( isInvertedLayout = false, topBar = { Column { - TopBarWithBackButton(stringRes(id = R.string.bookmarks), nav::popBack) + TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack) TabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, 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/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt index 85cdc1d77..ad15db9ac 100644 --- 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/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt @@ -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.bookmarks.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note 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/bookmarkgroups/default/dal/BookmarkPrivateFeedViewModel.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedViewModel.kt index 3ddbd8e09..a0df51e0d 100644 --- 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/bookmarkgroups/default/dal/BookmarkPrivateFeedViewModel.kt @@ -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.bookmarks.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel 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/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt index 34a3962fb..e6e1ab1d8 100644 --- 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/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt @@ -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.bookmarks.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note 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/bookmarkgroups/default/dal/BookmarkPublicFeedViewModel.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedViewModel.kt index fb8ec6c8e..96b71053e 100644 --- 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/bookmarkgroups/default/dal/BookmarkPublicFeedViewModel.kt @@ -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.bookmarks.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index e4ca1fc88..b4d21b5b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRow 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 @@ -59,6 +58,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -118,7 +118,7 @@ fun BookmarkGroupScreenView( Scaffold( topBar = { Column { - TopAppBar( + ShorterTopAppBar( title = { TitleAndDescription(bookmarkGroupViewModel) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index 9e603d023..dba0abacc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -20,34 +20,43 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType 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.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import kotlinx.coroutines.flow.StateFlow @Composable fun ListOfBookmarkGroupsFeedView( + defaultBookmarks: BookmarkListState, groupListFeedSource: StateFlow>, + openDefaultBookmarks: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -56,40 +65,73 @@ fun ListOfBookmarkGroupsFeedView( ) { val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle() - if (bookmarkGroupFeedState.isEmpty()) { - BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) - } else { - LazyColumn( - state = rememberLazyListState(), - contentPadding = FeedPadding, - ) { - itemsIndexed( - bookmarkGroupFeedState, - key = { _: Int, item: LabeledBookmarkList -> item.identifier }, - ) { _, groupItem -> - BookmarkGroupItem( - modifier = Modifier.fillMaxSize().animateItem(), - bookmarkList = groupItem, - onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) }, - onRename = { onRenameItem(groupItem) }, - onDescriptionChange = { onItemDescriptionChange(groupItem) }, - onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) }, - onDelete = { onDeleteItem(groupItem) }, - ) - HorizontalDivider(thickness = DividerThickness) - } + LazyColumn( + state = rememberLazyListState(), + contentPadding = FeedPadding, + ) { + item { + DefaultBookmarkList(defaultBookmarks, openDefaultBookmarks) + HorizontalDivider(thickness = DividerThickness) + } + + itemsIndexed( + bookmarkGroupFeedState, + key = { _: Int, item: LabeledBookmarkList -> item.identifier }, + ) { _, groupItem -> + BookmarkGroupItem( + modifier = Modifier.fillMaxSize().animateItem(), + bookmarkList = groupItem, + onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) }, + onRename = { onRenameItem(groupItem) }, + onDescriptionChange = { onItemDescriptionChange(groupItem) }, + onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) }, + onDelete = { onDeleteItem(groupItem) }, + ) + HorizontalDivider(thickness = DividerThickness) } } } @Composable -fun BookmarkGroupsFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) { - Column( - Modifier.fillMaxSize().padding(horizontal = Size40dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(message) - Spacer(modifier = StdVertSpacer) - } +fun DefaultBookmarkList( + defaultBookmarks: BookmarkListState, + openDefaultBookmarks: () -> Unit, +) { + val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle() + + ListItem( + modifier = Modifier.clickable(onClick = openDefaultBookmarks), + headlineContent = { + Text(stringRes(R.string.bookmarks_title), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringRes(R.string.bookmarks_explainer), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Outlined.BookmarkBorder, + contentDescription = stringRes(R.string.bookmark_list_icon_label), + modifier = Size40Modifier, + ) + Spacer(StdVertSpacer) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.CenterHorizontally), + postBookmarksSize = bookmarkState.public.size + bookmarkState.private.size, + articleBookmarksSize = 0, + ) + } + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 8903b1b48..6e5ad7581 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -49,7 +50,9 @@ fun ListOfBookmarkGroupsScreen( nav: INav, ) { ListOfBookmarkGroupsFeed( + defaultBookmarks = accountViewModel.account.bookmarkState, listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, + openDefaultBookmarks = { nav.nav(Route.Bookmarks) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) @@ -84,7 +87,9 @@ fun ListOfBookmarkGroupsScreen( @Composable fun ListOfBookmarkGroupsFeed( + defaultBookmarks: BookmarkListState, listSource: StateFlow>, + openDefaultBookmarks: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -109,7 +114,9 @@ fun ListOfBookmarkGroupsFeed( ).fillMaxHeight(), ) { ListOfBookmarkGroupsFeedView( + defaultBookmarks = defaultBookmarks, groupListFeedSource = listSource, + openDefaultBookmarks = openDefaultBookmarks, onOpenItem = openBookmarkGroup, onRenameItem = renameBookmarkGroup, onItemDescriptionChange = changeBookmarkGroupDescription, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt deleted file mode 100644 index 2f8680c6e..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list - -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.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -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.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer - -@Composable -fun NewBookmarkGroupCreationDialog( - modifier: Modifier = Modifier, - onDismiss: () -> Unit, - onCreateGroup: (name: String, description: String?) -> Unit, -) { - val newGroupName = remember { mutableStateOf("") } - val newGroupDescription = remember { mutableStateOf(null) } - - AlertDialog( - modifier = modifier, - onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "New Bookmark Group", - ) - } - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(5.dp), - ) { - // For the new bookmark group name - TextField( - value = newGroupName.value, - onValueChange = { newGroupName.value = it }, - label = { - Text(text = "Group name") - }, - ) - Spacer(modifier = DoubleVertSpacer) - // For the group description - TextField( - value = - (if (newGroupDescription.value != null) newGroupDescription.value else "").toString(), - onValueChange = { newGroupDescription.value = it }, - label = { - Text(text = "Group description(optional)") - }, - ) - } - }, - confirmButton = { - Button( - onClick = { - onCreateGroup(newGroupName.value, newGroupDescription.value) - onDismiss() - }, - ) { - Text("Create Group") - } - }, - dismissButton = { - Button( - onClick = onDismiss, - ) { - Text(stringRes(R.string.cancel)) - } - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt index ec36b977e..a5614fdf9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt @@ -44,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType -import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.Address @@ -75,8 +74,6 @@ private fun ListManagementView( accountViewModel: AccountViewModel, nav: INav, ) { - val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow - .collectAsStateWithLifecycle() Scaffold( modifier = modifier, topBar = { @@ -95,48 +92,86 @@ private fun ListManagementView( ).consumeWindowInsets(contentPadding) .imePadding(), ) { - if (bookmarkGroups.isEmpty()) { - BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) - } else { - LazyColumn( - state = rememberLazyListState(), - modifier = Modifier.fillMaxWidth(), - ) { - itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> - val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address } - val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address } - BookmarkGroupManagementItem( - modifier = Modifier.fillMaxWidth().animateItem(), - listTitle = bookmarkList.title, - isPublicMemberBookmark = maybePublicBookmark != null, - isPrivateMemberBookmark = maybePrivateBookmark != null, - totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, - totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size, - onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) }, - onAddBookmarkToGroup = { shouldBePrivate -> - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.addBookmarkToList( - bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()), - bookmarkListIdentifier = bookmarkList.identifier, - isBookmarkPrivate = shouldBePrivate, - account = accountViewModel.account, - ) - } - }, - onRemoveBookmarkFromGroup = { - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( - bookmark = AddressBookmark(address = note.address), - bookmarkListIdentifier = bookmarkList.identifier, - isBookmarkPrivate = maybePrivateBookmark != null, - account = accountViewModel.account, - ) - } - }, - ) - } - } - } + ListManagementViewBody(note, accountViewModel, nav) + } + } +} + +@Composable +private fun ListManagementViewBody( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow + .collectAsStateWithLifecycle() + + val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle() + + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxWidth(), + ) { + item { + val maybePublicBookmark = defaultBookmarks.public.contains(note) + val maybePrivateBookmark = defaultBookmarks.private.contains(note) + + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = stringRes(R.string.bookmarks_title), + isPublicMemberBookmark = maybePublicBookmark, + isPrivateMemberBookmark = maybePrivateBookmark, + totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size, + totalArticleBookmarkSize = 0, + onClick = { + nav.nav(Route.Bookmarks) + }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.addBookmark(note, shouldBePrivate) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.removeBookmark(note) + } + }, + ) + } + + itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> + val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address } + val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address } + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = bookmarkList.title, + isPublicMemberBookmark = maybePublicBookmark != null, + isPrivateMemberBookmark = maybePrivateBookmark != null, + totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, + totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size, + onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.addBookmarkToList( + bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = shouldBePrivate, + account = accountViewModel.account, + ) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( + bookmark = AddressBookmark(address = note.address), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = maybePrivateBookmark != null, + account = accountViewModel.account, + ) + } + }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt index 14bb6478d..e38ea0a9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt @@ -44,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType -import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark @@ -74,8 +73,6 @@ private fun ListManagementView( accountViewModel: AccountViewModel, nav: INav, ) { - val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow - .collectAsStateWithLifecycle() Scaffold( modifier = modifier, topBar = { @@ -94,48 +91,86 @@ private fun ListManagementView( ).consumeWindowInsets(contentPadding) .imePadding(), ) { - if (bookmarkGroups.isEmpty()) { - BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) - } else { - LazyColumn( - state = rememberLazyListState(), - modifier = Modifier.fillMaxWidth(), - ) { - itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> - val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex } - val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex } - BookmarkGroupManagementItem( - modifier = Modifier.fillMaxWidth().animateItem(), - listTitle = bookmarkList.title, - isPublicMemberBookmark = maybePublicBookmark != null, - isPrivateMemberBookmark = maybePrivateBookmark != null, - totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, - totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size, - onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) }, - onAddBookmarkToGroup = { shouldBePrivate -> - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.addBookmarkToList( - bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex), - bookmarkListIdentifier = bookmarkList.identifier, - isBookmarkPrivate = shouldBePrivate, - account = accountViewModel.account, - ) - } - }, - onRemoveBookmarkFromGroup = { - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( - bookmark = EventBookmark(eventId = note.idHex), - bookmarkListIdentifier = bookmarkList.identifier, - isBookmarkPrivate = maybePrivateBookmark != null, - account = accountViewModel.account, - ) - } - }, - ) - } - } - } + ListManagementViewBody(note, accountViewModel, nav) + } + } +} + +@Composable +private fun ListManagementViewBody( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow + .collectAsStateWithLifecycle() + + val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle() + + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxWidth(), + ) { + item { + val maybePublicBookmark = defaultBookmarks.public.contains(note) + val maybePrivateBookmark = defaultBookmarks.private.contains(note) + + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = stringRes(R.string.bookmarks_title), + isPublicMemberBookmark = maybePublicBookmark, + isPrivateMemberBookmark = maybePrivateBookmark, + totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size, + totalArticleBookmarkSize = 0, + onClick = { + nav.nav(Route.Bookmarks) + }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.addBookmark(note, shouldBePrivate) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.removeBookmark(note) + } + }, + ) + } + + itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> + val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex } + val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex } + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = bookmarkList.title, + isPublicMemberBookmark = maybePublicBookmark != null, + isPrivateMemberBookmark = maybePrivateBookmark != null, + totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, + totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size, + onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.addBookmarkToList( + bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = shouldBePrivate, + account = accountViewModel.account, + ) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( + bookmark = EventBookmark(eventId = note.idHex), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = maybePrivateBookmark != null, + account = accountViewModel.account, + ) + } + }, + ) } } } 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 index dd6676f11..9b434ffce 100644 --- 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 @@ -25,9 +25,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.ListChange import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter -import com.vitorpamplona.amethyst.ui.dal.ListChange import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt index ac8b756a2..ca20492aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -33,6 +33,7 @@ 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.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -49,12 +50,13 @@ class FollowPackFeedNewThreadFeedFilter( val followPackNote: AddressableNote, val account: Account, ) : AdditiveFeedFilter() { - companion object Companion { + companion object { val ADDRESSABLE_KINDS = listOf( AudioTrackEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, + NipTextEvent.KIND, ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, ) 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 index 5b0c37659..307162570 100644 --- 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 @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Commen 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.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -58,6 +59,7 @@ val PostsByHashtagKinds2 = InteractiveStorySceneEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, + NipTextEvent.KIND, ) fun filterPostsByHashtags( 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 index 652cc425f..cd479b134 100644 --- 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 @@ -972,7 +972,6 @@ open class ShortNotePostViewModel : val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) - val uploadVoiceUnexpected = stringRes(appContext, R.string.upload_error_voice_message_unexpected_state) val uploadVoiceExceptionMessage: (String) -> String = { detail -> stringRes(appContext, R.string.upload_error_voice_message_exception, detail) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index da1dd4b1c..15f30bb19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.actions.uploads.formatSecondsToTime import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose @@ -212,29 +214,44 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) { onRecordTaken = { recording -> viewModel.selectRecording(recording) }, - ) { isRecording, _ -> + ) { isRecording, elapsedSeconds -> + val contentColor = + if (isRecording) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onBackground + } + val icon = + if (isRecording) { + Icons.Default.Stop + } else { + Icons.Default.Mic + } + val label = + if (isRecording) { + formatSecondsToTime(elapsedSeconds) + } else { + stringRes(id = R.string.re_record) + } + val iconDescription = + if (isRecording) { + stringRes(id = R.string.recording_indicator_description) + } else { + stringRes(id = R.string.record_a_message) + } Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( - imageVector = Icons.Default.Mic, - contentDescription = stringRes(id = R.string.record_a_message), - tint = - if (isRecording) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground - }, + imageVector = icon, + contentDescription = iconDescription, + tint = contentColor, ) Text( - text = stringRes(id = R.string.re_record), - color = - if (isRecording) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground - }, + text = label, + color = contentColor, ) } } 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 index 8e9eddd2b..b7c3ef37c 100644 --- 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 @@ -25,6 +25,7 @@ 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.experimental.nipsOnNostr.NipTextEvent 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 @@ -52,6 +53,7 @@ val HomePostsBuHashtagsKinds = InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, WikiNoteEvent.KIND, + NipTextEvent.KIND, VoiceEvent.KIND, VoiceReplyEvent.KIND, ) 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 index 46bebbc44..5ac4955e9 100644 --- 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 @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors 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.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -53,6 +54,7 @@ val HomePostsNewThreadKinds = LongTextNoteEvent.KIND, HighlightEvent.KIND, WikiNoteEvent.KIND, + NipTextEvent.KIND, PollNoteEvent.KIND, InteractiveStoryPrologueEvent.KIND, ) 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 index a914febf5..b96effdd4 100644 --- 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 @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Commu 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.experimental.nipsOnNostr.NipTextEvent 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 @@ -41,6 +42,7 @@ val HomePostsFromCommunityKinds = ClassifiedsEvent.KIND, HighlightEvent.KIND, WikiNoteEvent.KIND, + NipTextEvent.KIND, CommunityPostApprovalEvent.KIND, CommentEvent.KIND, InteractiveStoryPrologueEvent.KIND, @@ -53,6 +55,7 @@ val HomePostsFromCommunityKindsStr = ClassifiedsEvent.KIND.toString(), HighlightEvent.KIND.toString(), WikiNoteEvent.KIND.toString(), + NipTextEvent.KIND.toString(), CommunityPostApprovalEvent.KIND.toString(), CommentEvent.KIND.toString(), InteractiveStoryPrologueEvent.KIND.toString(), 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 e2ed1d22f..8dc2fd2f4 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 @@ -29,6 +29,8 @@ 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.BundledInsert +import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrderCard @@ -36,8 +38,6 @@ import com.vitorpamplona.amethyst.ui.dal.FeedFilter 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 import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent 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 16447e5f3..daa23cac1 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 @@ -29,10 +29,10 @@ 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.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.note.showCount -import com.vitorpamplona.ammolite.relays.BundledInsert import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index fac9b7661..b8a5ba010 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -29,13 +29,13 @@ 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.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.forks.forkFromVersion -import com.vitorpamplona.quartz.experimental.forks.isForkFromAddressWithPubkey +import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent 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.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -77,6 +77,7 @@ class NotificationFeedFilter( listOf( AudioTrackEvent.KIND, WikiNoteEvent.KIND, + NipTextEvent.KIND, ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, CalendarTimeSlotEvent.KIND, @@ -185,7 +186,7 @@ class NotificationFeedFilter( return true } - if (event is BaseThreadedEvent) { + if (event is BaseNoteEvent) { if (note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true) { return true } @@ -208,11 +209,23 @@ class NotificationFeedFilter( } val isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } - val isAuthorDirectlyCited = event.citedUsers().contains(authorHex) - val isAuthorOfAFork = - event.isForkFromAddressWithPubkey(authorHex) || (event.forkFromVersion()?.let { LocalCache.getNoteIfExists(it.eventId)?.author?.pubkeyHex == authorHex } == true) - return isAuthoredPostCited || isAuthorDirectlyCited || isAuthorOfAFork + if (isAuthoredPostCited) return true + + val isAuthorDirectlyCited = event.citedUsers().contains(authorHex) + + if (isAuthorDirectlyCited) return true + + return if (event is IForkableEvent && event.isAFork()) { + val address = event.forkFromAddress() + val version = event.forkFromVersion() + + // Displays notifications about forks + address?.pubKeyHex == authorHex || + (version?.let { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } == true) + } else { + false + } } if (event is ReactionEvent) { 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 730d1dc3e..41a7cdca6 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 @@ -36,5 +36,5 @@ fun BookmarkTabHeader( ) { val userBookmarks by observeUserBookmarkCount(baseUser, accountViewModel) - Text(text = "$userBookmarks ${stringRes(R.string.bookmarks)}") + Text(text = "$userBookmarks ${stringRes(R.string.bookmarks_title)}") } 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 index dbbd86c0e..991ed0f80 100644 --- 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 @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.LocalCache 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.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -58,6 +59,7 @@ val UserProfilePostKinds2 = listOf( TorrentEvent.KIND, TorrentCommentEvent.KIND, + NipTextEvent.KIND, InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, VoiceReplyEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index 22a90a03e..dbb4981bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -30,6 +30,7 @@ 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 +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent @@ -76,6 +77,7 @@ class UserProfileMutualFeedFilter( it.event is GenericRepostEvent || it.event is LongTextNoteEvent || it.event is WikiNoteEvent || + it.event is NipTextEvent || it.event is PollNoteEvent || it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index 3b4b1b727..4aadd0277 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -30,6 +30,7 @@ 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 +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -77,6 +78,7 @@ class UserProfileNewThreadFeedFilter( it.event is GenericRepostEvent || it.event is LongTextNoteEvent || it.event is WikiNoteEvent || + it.event is NipTextEvent || it.event is PollNoteEvent || it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || 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 1619707db..7fa6baad1 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 @@ -24,10 +24,10 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists -import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList 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 index ce9cc9466..5e4fc7b20 100644 --- 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 @@ -20,65 +20,113 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays +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.PaddingValues 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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.automirrored.filled.ContactSupport +import androidx.compose.material.icons.automirrored.filled.Label +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.EditOff +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.Gavel +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Payment +import androidx.compose.material.icons.filled.PrivacyTip +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.Topic +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip 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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily 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.sp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo 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.components.appendLink +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.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserCompose -import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.graspLink +import com.vitorpamplona.amethyst.ui.note.nipLink +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot 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.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.Size100dp 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.core.EmptyTagList -import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayDebugMessage +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.NoticeDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.SpamDebugMessage 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.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract @Composable fun RelayInformationScreen( @@ -138,267 +186,177 @@ fun RelayInformationScreen( .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(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - pingInMs = - Amethyst.instance.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) - } - } + RelayInformationBody(relay, relayInfo, Amethyst.instance.relayStats.get(relay), messages, pad, accountViewModel, nav) } } @Composable -private fun RenderDebugMessage( - msg: RelayDebugMessage, +fun RelayInformationBody( + relay: NormalizedRelayUrl, + relayInfo: Nip11RelayInformation, + relayStats: RelayStat, + messages: ImmutableList, + pad: PaddingValues, accountViewModel: AccountViewModel, - newNav: INav, + nav: 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, - ) - } -} + LazyColumn( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .fillMaxSize(), + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // 1. Header Section + item { + RelayHeader(relay, relayStats, relayInfo, accountViewModel) + } -@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", - ) + val targetAudience = + relayInfo.tags != null || + relayInfo.language_tags != null || + relayInfo.relay_countries != null + + if (targetAudience) { + item { SectionHeader(stringRes(R.string.target_audience)) } + item { TargetAudienceCard(relayInfo, nav) } + } + + relayInfo.pubkey?.let { + item { + SectionHeader(stringRes(R.string.owner)) + DisplayOwnerInformation(it, accountViewModel, nav) } } - 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", - ) + relayInfo.self?.let { + item { + SectionHeader(stringRes(R.string.self)) + DisplayOwnerInformation(it, accountViewModel, nav) } } + + item { SectionHeader(stringRes(R.string.policies_and_links)) } + item { PoliciesCard(relayInfo) } + + relayInfo.fees?.let { fees -> + item { SectionHeader(stringRes(R.string.fees_and_payments)) } + item { FeesCard(fees, relayInfo.payments_url) } + } + + relayInfo.limitation?.let { + item { SectionHeader(stringRes(R.string.limitations)) } + item { LimitationsCard(it) } + } + + val atLeastOneSoftware = + relayInfo.software != null || + relayInfo.version != null || + relayInfo.supported_grasps != null || + relayInfo.supported_nips != null + + if (atLeastOneSoftware) { + item { SectionHeader(stringRes(R.string.software)) } + item { SoftwareCard(relayInfo) } + } + + item { + SectionHeader(stringRes(R.string.relay_error_messages)) + } + + items(messages) { msg -> + RenderDebugMessage(msg) + + Spacer(modifier = StdVertSpacer) + } } } @Composable -private fun DisplaySoftwareInformation(software: String) { - val url = software.replace("git+", "") - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = url, - url = url, - ) +private fun RenderDebugMessage(msg: IRelayDebugMessage) { + val context = LocalContext.current + + Column(Modifier.padding(horizontal = 12.dp)) { + Row( + modifier = Modifier.padding(vertical = 6.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Timestamp with Monospace font for alignment + Text( + text = timeAgoNoDot(msg.time, context), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + + // Type Tag + Text( + text = + when (msg) { + is ErrorDebugMessage -> stringRes(R.string.errors) + is NoticeDebugMessage -> stringRes(R.string.relay_notice) + is SpamDebugMessage -> stringRes(R.string.spam) + }, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + ), + color = + when (msg) { + is ErrorDebugMessage -> MaterialTheme.colorScheme.error + is NoticeDebugMessage -> MaterialTheme.colorScheme.primary + is SpamDebugMessage -> MaterialTheme.colorScheme.outline + }, + ) + } + + when (msg) { + is ErrorDebugMessage -> + SelectionContainer { + Text( + text = msg.message, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + is NoticeDebugMessage -> + SelectionContainer { + Text( + text = msg.message, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + is SpamDebugMessage -> + SelectionContainer { + val uri = LocalUriHandler.current + val start = stringRes(R.string.duplicated_post) + Text( + text = + remember { + buildAnnotatedString { + append(start) + append(" ") + appendLink(msg.link1) { + runCatching { + uri.openUri(msg.link1) + } + } + appendLink(msg.link2) { + runCatching { + uri.openUri(msg.link2) + } + } + } + }, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } } @@ -408,50 +366,616 @@ private fun DisplayOwnerInformation( accountViewModel: AccountViewModel, nav: INav, ) { - LoadUser(baseUserHex = userHex, accountViewModel) { - CrossfadeIfEnabled(it, accountViewModel = accountViewModel) { + LoadUser(baseUserHex = userHex, accountViewModel) { loadedUser -> + CrossfadeIfEnabled(loadedUser, accountViewModel = accountViewModel) { if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav, - ) + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } } @Composable -fun Title(text: String) { +private fun RelayHeader( + relay: NormalizedRelayUrl, + relayStats: RelayStat, + relayInfo: Nip11RelayInformation, + accountViewModel: AccountViewModel, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + RenderRelayIcon( + displayUrl = relay.displayUrl(), + iconUrl = relayInfo.icon, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + pingInMs = relayStats.pingInMs, + iconModifier = + Modifier + .size(Size100dp) + .clip(shape = CircleShape), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = relayInfo.description ?: relay.displayUrl(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun FeesCard( + fees: Nip11RelayInformation.RelayInformationFees, + payUrl: String?, +) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + fees.admission?.forEach { + FeeRow(stringRes(R.string.admission), it) + } + fees.subscription?.forEach { + FeeRow(stringRes(R.string.subscription), it) + } + fees.publication?.forEach { + FeeRow(stringRes(R.string.publication), it) + } + payUrl?.let { + val uri = LocalUriHandler.current + ClickableInfoRow(Icons.Default.Payment, stringRes(R.string.payments_url), it.removePrefix("https://")) { + runCatching { + uri.openUri(it) + } + } + } + } + } +} + +@Composable +fun LimitationsCard(lim: Nip11RelayInformation.RelayInformationLimitation) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val atLeastOneAccessControl = + lim.auth_required != null || + lim.payment_required != null || + lim.restricted_writes != null || + lim.min_pow_difficulty != null || + lim.min_prefix != null + + if (atLeastOneAccessControl) { + Column { + Text(stringRes(R.string.access_control), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + val yes = stringRes(R.string.yes) + val no = stringRes(R.string.no) + + lim.auth_required?.let { + InfoRow(Icons.Default.History, stringRes(R.string.auth_required), if (it) yes else no) + } + lim.payment_required?.let { + InfoRow(Icons.Default.Lock, stringRes(R.string.payment_required), if (it) yes else no) + } + lim.restricted_writes?.let { + InfoRow(Icons.Default.EditOff, stringRes(R.string.restricted_writes), if (it) yes else no) + } + + val minPoW = lim.min_pow_difficulty + + if (minPoW != null && minPoW > 0) { + InfoRow(Icons.Default.Bolt, stringRes(R.string.minimum_pow), stringRes(R.string.amount_in_bits, minPoW)) + } else { + lim.min_prefix?.let { + if (it > 0) { + InfoRow(Icons.Default.Key, stringRes(R.string.minimum_prefix), stringRes(R.string.amount_in_bits, it * 8)) + } + } + } + } + } + + val atLeastOneConnectivity = + lim.max_message_length.isNotNullAndNotZero() || + lim.max_subscriptions.isNotNullAndNotZero() || + lim.max_filters.isNotNullAndNotZero() || + lim.max_limit.isNotNullAndNotZero() || + lim.default_limit.isNotNullAndNotZero() || + lim.max_subid_length.isNotNullAndNotZero() + + if (atLeastOneConnectivity) { + Column { + Text(stringRes(R.string.connectivity), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + lim.max_message_length?.let { + InfoRow(Icons.AutoMirrored.Default.Message, stringRes(R.string.max_message_length), "${it / 1024} kb") + } + lim.max_subscriptions?.let { + InfoRow(Icons.Default.Dns, stringRes(R.string.max_subs), it.toString()) + } + lim.max_filters?.let { + InfoRow(Icons.Default.FilterAlt, stringRes(R.string.max_filters_per_sub), it.toString()) + } + lim.max_limit?.let { + InfoRow(Icons.AutoMirrored.Default.List, stringRes(R.string.max_limit_events_returning), it.toString()) + } + lim.default_limit?.let { + InfoRow(Icons.AutoMirrored.Default.List, stringRes(R.string.max_limit_events_returning), it.toString()) + } + lim.max_subid_length?.let { + InfoRow(Icons.AutoMirrored.Default.Label, stringRes(R.string.max_subid_length), it.toString()) + } + } + } + + val atLeastOneContentSize = + lim.max_event_tags != null || + lim.max_content_length != null + + if (atLeastOneContentSize) { + Column { + Text(stringRes(R.string.content_size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + lim.max_event_tags?.let { + InfoRow(Icons.Default.Tag, stringRes(R.string.maximum_event_tags), it.toString()) + } + + lim.max_content_length?.let { + InfoRow(Icons.AutoMirrored.Default.Article, stringRes(R.string.max_content_length), "${it / 1024} kb") + } + } + } + + val atLeastOneRestriction = + lim.created_at_lower_limit.isNotNullAndNotZero() || + lim.created_at_upper_limit.isNotNullAndNotZero() + + if (atLeastOneRestriction) { + Column { + Text(stringRes(R.string.event_retention), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + lim.created_at_lower_limit?.let { + if (it > 0) { + InfoRow(Icons.Default.History, stringRes(R.string.discards_older_than), stringRes(R.string.time_in_the_past, timeDiffAgoShortish(it))) + } + } + lim.created_at_upper_limit?.let { + if (it > 0) { + InfoRow(Icons.Default.History, stringRes(R.string.accepts_up_to), stringRes(R.string.time_in_the_future, timeDiffAgoShortish(it))) + } + } + } + } + } + } +} + +@OptIn(ExperimentalContracts::class) +fun Int?.isNotNullAndNotZero(): Boolean { + contract { + returns(true) implies (this@isNotNullAndNotZero != null) + } + + return this != null && this != 0 +} + +@Composable +fun SoftwareCard(relayInfo: Nip11RelayInformation) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + val uri = LocalUriHandler.current + relayInfo.software?.let { + if (it.contains("https://")) { + ClickableInfoRow(Icons.Default.Code, stringRes(R.string.software), it.removePrefix("git+https://").removePrefix("https://")) { + runCatching { + uri.openUri(it.removePrefix("git+")) + } + } + } else { + InfoRow(Icons.Default.Code, stringRes(R.string.software), it) + } + } + + relayInfo.version?.let { + InfoRow(Icons.Default.Storage, stringRes(R.string.version), it) + } + + relayInfo.supported_nips?.let { nips -> + Text( + stringRes(R.string.supports), + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + val uri = LocalUriHandler.current + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + nips.forEach { nip -> + val nipStr = nip.padStart(2, '0') + SuggestionChip( + onClick = { + runCatching { + uri.openUri(nipLink(nipStr)) + } + }, + label = { + Text(nipStr) + }, + ) + } + } + } + + relayInfo.supported_grasps?.let { grasps -> + Text( + stringRes(R.string.supported_grasps), + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + val uri = LocalUriHandler.current + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + grasps.forEach { grasp -> + val graspStr = grasp.padStart(2, '0') + SuggestionChip( + onClick = { + runCatching { + uri.openUri(graspLink(graspStr)) + } + }, + label = { + Text(graspStr) + }, + ) + } + } + } + } + } +} + +@Composable +fun TargetAudienceCard( + relay: Nip11RelayInformation, + nav: INav, +) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + relay.tags?.let { tags -> + if (tags.size > 2) { + Column { + Text(stringRes(R.string.topics), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + tags.forEach { tag -> + SuggestionChip( + onClick = { nav.nav(Route.Hashtag(tag)) }, + label = { + Text(tag) + }, + ) + } + } + } + } else if (tags.isNotEmpty()) { + InfoRow(Icons.Default.Topic, stringRes(R.string.topics), tags.joinToString()) + } + } + relay.relay_countries?.let { countries -> + val allCountries = stringRes(R.string.all_countries) + if (countries.size > 2) { + Column { + Text(stringRes(R.string.countries), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + countries.forEach { country -> + if (country == "*") { + SuggestionChip( + onClick = { }, + label = { + Text(allCountries) + }, + ) + } else { + SuggestionChip( + onClick = { }, + label = { + Text(country) + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } else if (countries.isNotEmpty()) { + InfoRow( + Icons.Default.Language, + stringRes(R.string.countries), + countries.joinToString { + if (it == "*") allCountries else it + }, + ) + } + } + relay.language_tags?.let { languages -> + val allLang = stringRes(R.string.all_languages) + if (languages.size > 2) { + Column { + Text(stringRes(R.string.languages), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + languages.forEach { lang -> + if (lang == "*") { + SuggestionChip( + onClick = { }, + label = { + Text(allLang) + }, + ) + } else { + SuggestionChip( + onClick = { }, + label = { + Text(lang) + }, + ) + } + } + } + } + } else if (languages.isNotEmpty()) { + InfoRow( + Icons.Default.Translate, + stringRes(R.string.languages), + languages.joinToString { + if (it == "*") allLang else it + }, + ) + } + } + } + } +} + +@Composable +fun PoliciesCard(relay: Nip11RelayInformation) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + val uri = LocalUriHandler.current + relay.contact?.let { + if (it.contains("@")) { + ClickableInfoRow(Icons.AutoMirrored.Default.ContactSupport, stringRes(R.string.contact), it) { + runCatching { + uri.openUri("mailto:$it") + } + } + } else { + InfoRow(Icons.AutoMirrored.Default.ContactSupport, stringRes(R.string.contact), it) + } + } + + relay.posting_policy?.let { + ClickableInfoRow(Icons.Default.EditNote, stringRes(R.string.posting_policy), it) { + runCatching { + uri.openUri(it) + } + } + } + + val pp = relay.privacy_policy + + if (pp != null) { + ClickableInfoRow(Icons.Default.PrivacyTip, stringRes(R.string.privacy_policy), pp.removePrefix("https://")) { + runCatching { + uri.openUri(pp) + } + } + } else { + InfoRow(Icons.Default.PrivacyTip, stringRes(R.string.privacy_policy), stringRes(R.string.not_available_acronym)) + } + + val ts = relay.terms_of_service + if (ts != null) { + ClickableInfoRow(Icons.Default.Gavel, stringRes(R.string.terms_and_conditions), ts.removePrefix("https://")) { + runCatching { + uri.openUri(ts) + } + } + } else { + InfoRow(Icons.Default.Gavel, stringRes(R.string.terms_and_conditions), stringRes(R.string.not_available_acronym)) + } + } + } +} + +@Composable +fun SectionHeader(title: String) { Text( - text = text, + text = title, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - fontSize = 24.sp, + modifier = Modifier.padding(top = 5.dp), + color = MaterialTheme.colorScheme.primary, ) } @Composable -fun SubtitleContent(text: String) { - Text( - text = text, - ) +private fun InfoRow( + icon: ImageVector, + label: String, + value: String, +) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.secondary) + Spacer(Modifier.width(12.dp)) + Text(text = label, style = MaterialTheme.typography.labelLarge, maxLines = 1) + Text(text = value, textAlign = TextAlign.End, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) + } } @Composable -fun Section(text: String) { - Spacer(modifier = DoubleVertSpacer) - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Spacer(modifier = DoubleVertSpacer) +private fun ClickableInfoRow( + icon: ImageVector, + label: String, + value: String, + onClickValue: () -> Unit, +) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.secondary) + Spacer(Modifier.width(12.dp)) + Text(text = label, style = MaterialTheme.typography.labelLarge, maxLines = 1) + Text(text = value, textAlign = TextAlign.End, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable(onClick = onClickValue).weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) + } } @Composable -fun SectionContent(text: String) { - Text( - modifier = Modifier.padding(start = 10.dp), - text = text, - ) +fun FeeRow( + label: String, + fee: Nip11RelayInformation.RelayInformationFee, +) { + fee.amount?.let { + val period = fee.period + val combinedLabel = + if (period != null) { + label + " (${timeDiffAgoShortish(period)})" + } else { + label + } + + if (fee.unit == "msats") { + InfoRow(Icons.Default.AttachMoney, combinedLabel, "${it / 1000} sats") + } else { + InfoRow(Icons.Default.AttachMoney, combinedLabel, "$it ${fee.unit}") + } + } +} + +@Composable +@Preview(showBackground = true, name = "Nost.wine Relay Info", device = "spec:width=2160px,height=5640px,dpi=440") +fun RelayHeaderPreview() { + ThemeComparisonRow { + RelayInformationBody( + relay = NormalizedRelayUrl("wss://nostr.wine/"), + relayInfo = + Nip11RelayInformation( + name = "Nostr.wine", + icon = "https://image.nostr.build/30acdce4a81926f386622a07343228ae99fa68d012d54c538c0b2129dffe400c.png", + description = "A paid nostr relay for wine enthusiasts and everyone else", + software = "https://nostr.wine", + contact = "wino@nostr.wine", + version = "0.3.3", + self = "4918eb332a41b71ba9a74b1dc64276cfff592e55107b93baae38af3520e55975", + pubkey = "4918eb332a41b71ba9a74b1dc64276cfff592e55107b93baae38af3520e55975", + payments_url = "https://nostr.wine/invoices", + privacy_policy = "https://nostr.wine/terms", + terms_of_service = "https://nostr.wine/terms", + tags = listOf("Bitcoin", "Amethyst"), + supported_nips = listOf("1", "2", "4", "9", "11", "40", "42", "50", "70", "77"), + relay_countries = listOf("*"), + language_tags = listOf("*"), + limitation = + Nip11RelayInformation.RelayInformationLimitation( + auth_required = false, + created_at_lower_limit = 94608000, + created_at_upper_limit = 300, + max_event_tags = 4000, + max_limit = 1000, + default_limit = 20, + max_message_length = 524288, + max_subid_length = 71, + max_subscriptions = 50, + min_pow_difficulty = 0, + payment_required = true, + restricted_writes = true, + ), + fees = + Nip11RelayInformation.RelayInformationFees( + admission = + listOf( + Nip11RelayInformation.RelayInformationFee( + amount = 3000, + unit = "msats", + period = 2628003, + ), + Nip11RelayInformation.RelayInformationFee( + amount = 8000, + unit = "sats", + period = 7884009, + ), + ), + ), + supported_grasps = listOf("GRASP-01"), + ), + pad = PaddingValues(0.dp), + relayStats = RelayStat(), + messages = + persistentListOf( + NoticeDebugMessage( + time = TimeUtils.now(), + message = "Subscription closed: AccountNotificationsEoseFromRandomRelaysManagerugZU9o auth-required: At least one matching event requires AUTH", + ), + ErrorDebugMessage( + time = TimeUtils.now() - 24000, + message = "No such subscription", + ), + SpamDebugMessage( + time = TimeUtils.now() - 24000, + link1 = "http://test1.com", + link2 = "http://test2.com", + ), + ), + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } } 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 5d49b9a50..6741a207d 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 @@ -26,11 +26,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.BundledUpdate import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists -import com.vitorpamplona.ammolite.relays.BundledUpdate import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList 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 e3f5068b9..e3260439e 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 @@ -113,9 +113,9 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPos import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward -import com.vitorpamplona.amethyst.ui.note.elements.ForkInformationRow import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton 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.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount @@ -187,7 +187,7 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent -import com.vitorpamplona.quartz.experimental.forks.forkFromAddress +import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent @@ -508,11 +508,15 @@ private fun FullBleedNoteCompose( Column( remember { Modifier.weight(1f) }, ) { - ObserveDisplayNip05Status( - baseNote, - accountViewModel, - nav, - ) + if (noteEvent is IForkableEvent && noteEvent.isAFork()) { + ShowForkInformation(noteEvent, Modifier, accountViewModel, nav) + } else { + ObserveDisplayNip05Status( + baseNote, + accountViewModel, + nav, + ) + } } val geo = remember { noteEvent.geoHashOrScope() } @@ -1108,8 +1112,6 @@ private fun RenderWikiHeaderForThread( accountViewModel: AccountViewModel, nav: INav, ) { - val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() } - Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { Column { noteEvent.image()?.let { @@ -1135,14 +1137,6 @@ private fun RenderWikiHeaderForThread( ) } - forkedAddress?.let { - LoadAddressableNote(it, accountViewModel) { originalVersion -> - if (originalVersion != null) { - ForkInformationRow(originalVersion, Modifier.fillMaxWidth(), accountViewModel, nav) - } - } - } - noteEvent .summary() ?.ifBlank { null } 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 5f876fb25..a635810d9 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 @@ -215,13 +215,13 @@ val LightSelectedReactionBoxModifier = val DarkChannelNotePictureModifier = Modifier - .size(30.dp) + .size(20.dp) .clip(shape = CircleShape) .border(2.dp, DarkColorPalette.background, CircleShape) val LightChannelNotePictureModifier = Modifier - .size(30.dp) + .size(20.dp) .clip(shape = CircleShape) .border(2.dp, LightColorPalette.background, CircleShape) diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg index f0c93eb83..126d9edf6 100644 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg and b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg new file mode 100644 index 000000000..f0c93eb83 Binary files /dev/null and b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg differ diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index c32931f87..9605e4c53 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -15,6 +15,8 @@ Nepodařilo se dešifrovat zprávu Obrázek skupiny Explicitní obsah + Oznámení relé + Duplicitní příspěvek Nevyžádaná pošta Počet spamových událostí z tohoto relé Zneužívání identity @@ -121,6 +123,7 @@ Adresa relé Příspěvky Byty + Chyba Chyby Počet chyb připojení v této relaci Domovský kanál @@ -132,6 +135,8 @@ Přidat uživatele Přidat přeposílání Jméno + Jméno (pro @tagování) + Moje @tag jméno Zobrazované jméno Moje zobrazované jméno Ostrich McAwesome @@ -358,6 +363,8 @@ Blokovat Manuální zap rozdělení Záložky + Výchozí záložky + Vaše výchozí záložky, které mnoho klientů podporuje Koncepty Soukromé záložky Veřejné záložky @@ -639,28 +646,64 @@ Množství bajtů, které bylo přijato z tohoto relé, včetně filtrů a událostí Při pokusu o získání informací z Relay se vyskytla chyba z %1$s Vlastník + Servisní klíč + Spuštěno %1$s + Spuštěno %1$s (%2$s) Verze Programové vybavení Kontakt Podporované NIPs + Podporované GRASPy Vstupné + Vstup + Předplatné + Publikování + Platby %1$s URL plateb + Cílové publikum + Zásady & odkazy + Poplatky & platby Omezení Země Jazyky Tagy + Témata + Všechny země + Všechny jazyky Politika příspěvků + Zásady ochrany soukromí + Podmínky & ujednání + N/A Chyby a upozornění z tohoto relé Délka zprávy Předplatné Filtry Délka ID předplatného - Minimální předpona - Maximální počet značek události + Min. předpona + Max. značek události Délka obsahu - Minimální PoW + Max. délka obsahu + Zahazuje starší než + Přijímá až + %1$s v budoucnu + před %1$s + %1$s nul + %1$s bitů + Uchovávání událostí + Velikost obsahu + Připojení + Řízení přístupu + Min. obtížnost PoW Ověření + Vyžaduje se autentizace Platba + Vyžaduje se platba + Max. délka zprávy + Max. odběrů + Max. filtrů na odběr + Max. limit (vracené události) + Výchozí limit (vracené události) + Max. délka SubID Cashu Token Vyměnit Poslat do Zap peněženky @@ -1184,4 +1227,5 @@ Broadcast sady doporučení Smazat seznam Smazat sadu doporučení + Typy diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 2073348b2..7a521e907 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -15,6 +15,8 @@ Nachricht konnte nicht entschlüsselt werden Gruppenbild Anstößiger Inhalt + Relay-Hinweis + Doppelter Beitrag Spam Anzahl der Spam-Ereignisse von diesem Relais Vortäuschung @@ -121,6 +123,7 @@ Relay-Adresse Beiträge Bytes + Fehler Fehler Anzahl der Verbindungsfehler in dieser Sitzung Startseite @@ -132,6 +135,8 @@ Benutzer hinzufügen Relay hinzufügen Name + Name (zum @Taggen) + Mein @tag-Name Anzeigename Mein Anzeigename Ostrich McAwesome @@ -364,6 +369,8 @@ anz der Bedingungen ist erforderlich Blockieren Manuelle Zap-Splits Lesezeichen + Standard-Lesezeichen + Deine Standard-Lesezeichen, die viele Clients unterstützen Entwürfe Private Lesezeichen Öffentliche Lesezeichen @@ -644,28 +651,64 @@ anz der Bedingungen ist erforderlich Die Menge in Bytes, die von diesem Relais empfangen wurde, einschließlich Filter und Ereignisse Ein Fehler ist beim Abrufen von Relay-Informationen von %1$s aufgetreten Inhaber + Service-Schlüssel + %1$s wird ausgeführt + %1$s wird ausgeführt (%2$s) Version Programme Kontakt Unterstützte NIPs + Unterstützte GRASPs Eintrittsgebühren + Zutritt + Abonnement + Veröffentlichung + Zahlungen %1$s Zahlungs-URL + Zielgruppe + Richtlinien & Links + Gebühren & Zahlungen Einschränkungen Länder Sprachen Tags + Themen + Alle Länder + Alle Sprachen Veröffentlichungsrichtlinie + Datenschutzerklärung + Allgemeine Geschäftsbedingungen + N/A Fehler und Hinweise von diesem Relais Nachrichtenlänge Abonnements Filter Länge der Abonnement-ID Mindestpräfix - Maximale Anzahl von Ereignis-Tags + Max. Event-Tags Inhaltslänge - Mindest-PoW + Max. Inhaltslänge + Verwirft älter als + Akzeptiert bis zu + %1$s in der Zukunft + vor %1$s + %1$s Nullen + %1$s Bits + Ereignis-Aufbewahrung + Inhaltsgröße + Verbindung + Zugriffskontrolle + Min. PoW-Schwierigkeit Authentifizierung + Authentifizierung erforderlich Zahlung + Zahlung erforderlich + Max. Nachrichtenlänge + Max. Abonnements + Max. Filter pro Abo + Max. Limit (zurückgegebene Ereignisse) + Standardlimit (zurückgegebene Ereignisse) + Max. SubID-Länge Cashu-Token Einlösen An Zap Wallet senden @@ -1189,4 +1232,5 @@ anz der Bedingungen ist erforderlich Empfehlungspaket veröffentlichen Liste löschen Empfehlungspaket löschen + Typen diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index a863d2a1d..bbdb00c1e 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -15,12 +15,15 @@ Impossible de déchiffrer le message Image de groupe Contenu choquant + Annonce du serveur + Message en double Spam Le nombre d\'événements spam provenant de ce relais Falsification d\'identité Comportement illégal Autre Harcèlement + Violence inconnu Icône de relais Auteur inconnu @@ -100,10 +103,12 @@ Ajouter au message "Erreur d'analyse de l'aperçu pour %1$s : %2$s" "Aperçu de l'image pour %1$s" + Article Nouveau canal Nom de canal Mon groupe spectaculaire Url de l\'image + Url de l\'image (Optionnel) Description Description introuvable "à propos…" @@ -118,6 +123,7 @@ Adresse du relais Publications Octets + Erreur Erreurs Le nombre d\'erreurs de connexion dans cette session Vos notifications @@ -155,7 +161,15 @@ Enregistrer un message Enregistrer un message Cliquez et maintenez pour enregistrer un message + Ré-enregistrer + Enregistrer + Enregistrement %1$s Chargement… + Erreur d\'envoi + Échec de l\'envoi du message vocal + Erreur lors de l\'envoi + NIP-95 n\'est pas encore supporté pour les messages vocaux + Échec de l\'envoi de la voix : %1$s L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats "répondre ici" Copie l\'ID de note dans le presse-papiers pour le partage sur Nostr @@ -349,6 +363,7 @@ Bloquer Division manuelle de zaps Favoris + Signets par défaut Brouillons Favoris Privés Favoris Publics diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 9b9946c55..8fff23237 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -15,6 +15,8 @@ सन्देश का अरहस्यीकरण असफल समूह चित्र अभद्र विषयवस्तु + पुनःप्रसारक सूचना + दोहराया गया पत्र कचरालेख इस पुनःप्रसारक से कचरालेख घटनाओं की संख्या पररूपण @@ -121,6 +123,7 @@ पुनःप्रसारक पता प्रकाशित पत्र अष्टक + अपक्रम अपक्रम संयोजन अपक्रमों की संख्या इस सत्र में मुख्य सूचनावली @@ -132,6 +135,8 @@ प्रयोक्ता जोडें पुनःप्रसारक जोडें नाम + नाम (@सूचक के लिए) + मेरा @सूचक नाम प्रदर्शन नाम मेरा प्रदर्शन नाम उष्ट्रपक्षी मक्बढिया @@ -158,6 +163,7 @@ ऐक संदेश का अभिलेखन करें ऐक संदेश का अभिलेखन करें टाँकें तथा दबाए रखें संदेश का अभिलेखन करने के लिए + पुनरभिलेखन ध्वन्यभिलेखन ध्वन्यभिलेखन %1$s आरोहण चल रहा है… @@ -359,6 +365,8 @@ बाधित करें मनुष्यकृत ज्साप विभाजन स्मर्तव्य + मूलविकल्प स्मर्त्तव्य सूची + आपका मूलविकल्प स्मर्त्तव्य सूची जिसका अवलम्बन अनेक ग्राहक करते हैं पाण्डुलिपियाँ निजी स्मर्तव्य सार्वजनिक स्मर्तव्य @@ -642,17 +650,34 @@ अष्टकों में मात्रा जो इस पुनःप्रसारक से प्राप्त हुआ था छलनियाँ तथा घटनाएँ समेत %1$s से पुनःप्रसारक जानकारी प्राप्त करने के प्रयास में अपक्रम हुआ अधिपति + सेवा कुंजी + %1$s चलाया जा रहा है + %1$s (%2$s) चलाया जा रहा है संस्करण क्रमक सम्पर्क अवलम्बन किए हुए निप॰ सूची + अवलम्बित पकड प्रवेश शुल्क + प्रवेशन + ग्राहकता + प्रकाशन + भुगतान %1$s भुगतान जालपता + लक्ष्य दर्शक + नीतियाँ तथा जालयोजक + शुल्क तथा भुगतान परिसीमाएँ देश भाषाएँ विषयसूचक + विषय सूची + सभी देश + सभी भाषाएँ पत्र प्रकाशन नीति + गोपनीयता नीति + नियम तथा अवस्था + अनुपलब्ध अपक्रम तथा सूचनाएँ इस पुनःप्रसारक से संदेश लम्बाई ग्राहकताएँ @@ -661,9 +686,28 @@ न्यूनतम उपसर्ग अधिकतम घटना विषयसूचक विषयवस्तु लम्बाई + अधिकतम विषयवस्तु लम्बाई + इससे पुराना हटाता है + इस तक स्वीकारता है + %1$s भविष्य में + %1$s पूर्व + %1$s शून्यांक + %1$s द्वयंक + घटना रखाई + विषयवस्तु आकार + संयोजकता + अभिगमन नियन्त्रण न्यूनतम श्रमप्रमाण प्रमाणीकरण + प्रमाणीकरण आवश्यक भुगतान + भुगतान आवश्यक + अधिकतम संदेश लम्बाई + अधिकतम ग्राहकताएँ + अधिकतम छलनियाँ प्रति ग्राहकता + अधिकतम सीमा (घटनाएँ प्रतिफलित) + मूलविकल्प सीमा (घटनाएँ प्रतिफलित) + अधिकतम ग्राहकताविभेदक लम्बाई काशयु राशिखण्ड चुकाएँ ज्साप धनकोष को भेजें @@ -685,7 +729,7 @@ ये प्रयोक्ता सूचियाँ हैं जो आप अन्य लोगों के लिए अनुशंसित करते हैं। केवल सार्वजनिक प्रयोक्ता अनुमत। पठितव्य सूचनावली विधियाँ - पण्यक्षेत्र + व्यापारक्षेत्र तत्क्षणप्रसार समुदाय चर्चाएँ diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index fda052418..d6716c99c 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -15,6 +15,8 @@ Nem sikerült visszafejteni az üzenetet Csoport profilképe Szókimondó tartalom + Átjátszóval kapcsolatos megjegyzések + Duplikált bejegyzés Kéretlen tartalom Az erről az átjátszóról érkező kéretlen tartalmú események száma Profilutánzás @@ -121,6 +123,7 @@ Átjátszó címe Bejegyzések Bájt + Hiba Hibák Ebben a munkamenetben lévő kapcsolati hibák száma Fő hírfolyam @@ -132,6 +135,8 @@ Felhasználó ozzáadása Egy átjátszó hozzáadása Név + Név (az @említéshez) + Saját @említési név Megjelenítendő név Saját megjelenítendő név Strucc McNagyszerű @@ -158,6 +163,7 @@ 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 + Újrarögzítés Rögzítés %1$s rögzítése Feltöltés… @@ -359,6 +365,8 @@ Letiltás Kézi Zap-megosztás Könyvjelző + Alapértelmezett könyvjelzők + Az alapértelmezett könyvjelzők, amelyeket sok kliens támogat Piszkozatok Privát könyvjelzők Nyilvános könyvjelzők @@ -480,8 +488,8 @@ Adatvédelmi beállítások Tor és Orbot beállítása Kapcsolódás az Orbot beállításain keresztül - Beállítás - Tor beállítások + Módosítás + Tor-beállítások Kapcsolat bontása az Orbottal / Torral? Az Ön adatai azonnal átkerülnek a normál hálózatra Igen @@ -642,28 +650,64 @@ Az átjátszótól kapott bájt-mennyiség, beleértve a szűrőket és eseményeket is Hiba lépett fel, amikor megpróbálta lekérni az átjátszó-információt innen: %1$s Tulajdonos + Szolgáltatási kulcs + %1$s futtatása + %1$s (%2$s) futtatása Verzió Szoftver Kapcsolat Támogatott NIP-ek + Támogatott tartalomkezelők Csatlakozási díj + Felvétel + Előfizetés + Közzététel + %1$s kifizetések Fizetési webcím + Célközönség + Irányelvek és hivatkozások + Költségek és kifizetések Korlátozások Országok Nyelvek Címkék + Témák + Összes ország + Összes nyelv Közzétételi szabályzat + Adatvédelmi irányelvek + Általános szerződési feltételek + Nem érhető el Hibák és megjegyzések ettől az átjátszótól Üzenethossz Előfizetések Szűrők Előfizetés azonosítójának hossza - Minimális előtag - Maximális eseménycímkék + Minimális előtaghossz + Eseménycímkék száma legfeljebb Tartalomhossz - Minimális PoW + Tartalom hossza legfeljebb + Ettől régebbiek elvetése: + Elfogadások legfeljebb + %1$s a jövőben + %1$s ezelőtt + %1$s nulla + %1$s bit + Eseménymegőrzés + Tartalom mérete + Kapcsolat + Hozzáférés-vezérlés + Spam-szűrés erőssége Hitelesítés + Hitelesítés szükséges Fizetés + Fizetés szükséges + Üzenet hossza legfeljebb + Előfizetések legfeljebb + Szűrők száma feliratkozásonként + Korlátozás (visszatérő események) + Alapértelmezett korlátozás (visszatérő események) + Feliratkozás-azonosító hossza legfeljebb Cashu token Beváltás Küldés ide: Zap Wallet @@ -1188,4 +1232,5 @@ Közvetítési csomag Lista törlése Csomag törlése + Változatok diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index e0303c84f..8d5434880 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -15,6 +15,8 @@ Nie można odszyfrować wiadomości Zdjęcie grupy Niedozwolona zawartość + Uwagi transmitera + Zduplikowany post Spam Liczba spamu z tego transmitera Podszywanie się @@ -62,7 +64,7 @@ Zapy Liczba wyświetleń Powtórz - powtórzony + powtórzono edytowano edytuj #%1$s oryginalny @@ -121,6 +123,7 @@ Adres transmitera Posty Bajtów + Błąd Błędy Liczba błędów połączenia w tej sesji Główny kanał @@ -132,6 +135,8 @@ Dodaj użytkownika Dodaj Transmiter Imię + Imię (dla @tagging) + Moje imię @tag Nazwa użytkownika Mój nick G Braun @@ -158,6 +163,7 @@ Nagraj wiadomość Nagrywanie wiadomości Kliknij i przytrzymaj aby nagrać wiadomość + Nagraj ponownie Nagrywanie Nagrywanie %1$s Wgrywanie… @@ -356,6 +362,8 @@ Zablokuj Ręczny podział zapów Zakładki + Domyślne zakładki + Twoje domyślne zakładki obsługiwane przez wielu klientów Projekty Prywatne Zakładki Publiczne zakładki @@ -639,17 +647,34 @@ Ilość w bajtach, która została otrzymana z tego transmitera, w tym filtry i wydarzenia Wystąpił błąd podczas próby uzyskania informacji o transmiterze z %1$s Operator + Klucz dostępu do usług + Uruchamianie %1$s + Uruchamianie %1$s (%2$s) Wersja Oprogramowanie Kontakt Obsługiwane Nipy + Obsługiwany Grasps Opłaty za wstęp + Wstęp + Subskrypcja + Publikacja + Płatności %1$s Adres URL płatności + Odbiorcy docelowi + Zasady i linki + Opłaty i należności Ograniczenia Kraje Języki Tagi + Tematy + Wszystkie kraje + Wszystkie języki Polityka publikowania + Polityka Prywatności + Zasady i warunki użytkowania + Nie dotyczy Błędy i powiadomienia z tego transmitera Długość wiadomości Subskrybcje @@ -658,9 +683,28 @@ Minimalny prefiks Maksymalna ilość tagów wydarzeń Długość zawartości + Maks. długość treści + Odrzuć starsze niż + Akceptuje do + %1$s w przyszłości + %1$s temu + %1$s zer + %1$s bitów + Zapisywanie zdarzeń + Rozmiar treści + Podłączenie + Kontrola dostępu Minimalny poW Autoryzacja + Wymagane uwierzytelnienie Płatność + Wymagana płatność + Maksymalna długość wiadomości + Maks. subskrypcji + Maks. liczba filtrów na Suba + Maksymalny limit (Powracające zdarzenia) + Domyślny limit (Powracające zdarzenia) + Maks. długość SubID Cashu Token Wykup Wyślij do Zap Wallet @@ -1185,4 +1229,5 @@ Upowszechnij kategorię Usuń listę Usuń kategorię + Typy diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 6cbc364be..a3f8b297c 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -15,6 +15,8 @@ Não foi possível descriptografar a mensagem Imagem do grupo Conteúdo explícito + Aviso do relay + Post duplicado Spam O número de eventos de spam vindo deste relé Representação @@ -121,6 +123,7 @@ Endereço do Relay Postagens Bytes + Erro Erros O número de erros de conexão nesta sessão Feed principal @@ -132,6 +135,8 @@ Adicionar um usuário Adicionar um Relay Nome + Nome (para @marcar) + Meu nome de @tag Nome de Exibição Meu nome de exibição McAwesome Ostrich @@ -358,6 +363,8 @@ Bloquear Divisão manual de Zap Itens Salvos + Marcadores padrão + Seus marcadores padrão que muitos clientes suportam Rascunhos Itens Salvos Privados Itens Salvos Públicos @@ -639,28 +646,64 @@ A quantidade em bytes que foi recebida deste relé, incluindo filtros e eventos Ocorreu um erro ao tentar obter informações do relay de %1$s Proprietário + Chave de serviço + Executando %1$s + Executando %1$s (%2$s) Versão Programa Contato NIPs Suportados + GRASPs compatíveis Taxas de admissão + Entrada + Assinatura + Publicação + Pagamentos %1$s URL de pagamentos + Público-alvo + Políticas & links + Taxas & pagamentos Limitações Países Línguas Cerquilhas + Tópicos + Todos os países + Todos os idiomas Política de postagem + Política de privacidade + Termos & condições + N/A Erros e Avisos deste Relé Tamanho da mensagem Assinaturas Filtros Comprimento do ID da assinatura - Prefixo mínimo - Máximo de tags de evento + Prefixo mín. + Máx. tags de evento Tamanho do conteúdo - PoW mínimo + Máx. tamanho do conteúdo + Descarta mais antigos que + Aceita até + %1$s no futuro + há %1$s + %1$s zeros + %1$s bits + Retenção de eventos + Tamanho do conteúdo + Conectividade + Controle de acesso + Dificuldade mínima de PoW Autenticação + Autenticação obrigatória Pagamento + Pagamento obrigatório + Máx. tamanho da mensagem + Máx. inscrições + Máx. filtros por inscrição + Limite máx. (eventos retornados) + Limite padrão (eventos retornados) + Comprimento máx. do SubID Token Cashu Resgatar Enviar para Zap Wallet @@ -1184,4 +1227,5 @@ Transmitir pacote de recomendações Excluir lista Excluir pacote de recomendações + Tipos diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 6939e8824..7a56f1c6e 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -15,6 +15,8 @@ Kunde inte dekryptera meddelandet Grupp bild Explicit Innehåll + Relay-meddelande + Duplicerat inlägg Spam Antal skräpposthändelser från detta relä Imitation @@ -121,6 +123,7 @@ Relä Adress Inlägg Bytes + Fel Fel Antal anslutningsfel under denna session Hem Flöde @@ -132,6 +135,8 @@ Lägg till en användare Lägg till Relä Namn + Namn (för @taggning) + Mitt @tag-namn Visningsnamn Mitt visningsnamn Struts McAwesome @@ -358,6 +363,8 @@ Blockera Manuell Zap delning Bokmärken + Standardbokmärken + Dina standardbokmärken som många klienter stödjer Utkast Privata Bokmärken Publika Bokmärken @@ -638,28 +645,64 @@ Mängden data i byte som mottogs från detta relä, inklusive filter och händelser Ett fel inträffade vid försök att hämta information från Relay %1$s Ägare + Service-nyckel + Kör %1$s + Kör %1$s (%2$s) Version Programvara Kontakt Stödda NIPs + Stödda GRASPs Entréavgifter + Inträde + Prenumeration + Publicering + Betalningar %1$s Betalnings-URL + Målgrupp + Policyer & länkar + Avgifter & betalningar Begränsningar Länder Språk Taggar + Ämnen + Alla länder + Alla språk Publiceringspolicy + Integritetspolicy + Villkor & bestämmelser + N/A Fel och meddelanden från detta relä Meddelandelängd Prenumerationer Filter Längd på prenumerations-ID - Minsta prefix - Maximalt antal händelse-taggar + Min. prefix + Max. eventtaggar Innehållslängd - Minsta PoW + Max. innehållslängd + Kastar bort äldre än + Accepterar upp till + %1$s i framtiden + för %1$s sedan + %1$s nollor + %1$s bitar + Lagringstid för händelser + Innehållsstorlek + Anslutning + Åtkomstkontroll + Min. PoW-svårighet Autentisering + Autentisering krävs Betalning + Betalning krävs + Max. meddelandelängd + Max. prenumerationer + Max. filter per prenumeration + Maxgräns (returnerade händelser) + Standardgräns (returnerade händelser) + Max. SubID-längd Cashu Token Inlösen Skicka till Zap Plånbok @@ -1183,4 +1226,5 @@ Sänd rekommendationspaket Ta bort lista Ta bort rekommendationspaket + Typer diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index cc4646375..7f89d981a 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -21,6 +21,7 @@ Noqonuniy xatti-harakat Boshqa sabab Ta’qib + Zo\'ravon Noma\'lum Relay belgisi Nomaʼlum muallif @@ -45,4 +46,26 @@ 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 + Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat o‘qish huquqiga ega. Obuna bo‘lish uchun shaxsiy kalit bilan tizimga kiring + Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat o‘qish huquqiga ega. Yuklash uchun shaxsiy kalit bilan tizimga kiring + Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat o‘qish huquqiga ega. Xabarlarni imzolash uchun shaxsiy kalit bilan tizimga kiring + Ruxsatsiz deshifrlash + Imzolovchi ushbu operatsiyani amalga oshirish uchun zarur boʻlgan shifrlashni ochishga ruxsat bermadi. Imzolovchi ilovangizda NIP-44 shifrlashni ochish funksiyasini yoqing va qaytadan urinib koʻring + Imzolovchi ilova topilmadi + Imzolovchi ilova oʻchirib tashlandimi? Imzolovchi oʻrnatilganligini va ushbu hisobga ega ekanligini tekshiring. Agar imzolovchi ilova oʻzgargan boʻlsa, tizimdan chiqing va qayta kiring. + Zeplar + Koʻrishlar soni + Targʻib qilish + Targ\'ib qilidi + Tahrirlangan + Tahrir № %1$s + Asl + Iqtibos qilish + Nusxa olish + Tahrir taklif qilish + Qo\'shish + "Kimga javob yozilmoqda: " + " va " + "bu kanalda: " + Profil banneri diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index cb23aebc8..6cbc2e7d5 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -15,6 +15,8 @@ 无法解密消息 群聊图片 明确内容 + 中继通知 + 重复的贴文 垃圾信息 来自该中继的垃圾事件数量 冒充 @@ -121,6 +123,7 @@ 中继器地址 帖文 字节 + 错误 错误 该会话中产生的连接错误数量 主页 @@ -132,6 +135,8 @@ 添加用户 添加中继器 名称 + 名称 (用于 @ 标记时显示) + 我的 @ 名称 显示名称 我的显示名称 Nostr 太酷了 @@ -158,6 +163,7 @@ 录制消息 录制消息 点按以录制消息 + 重新录制 正在录制 正在录制 %1$s 上传中… @@ -359,6 +365,8 @@ 仅阻止 手动 Zap 拆分 书签 + 默认书签 + 许多客户端支持默认书签 草稿 私人书签 公开书签 @@ -642,17 +650,34 @@ 从该中继接收事件和过滤响应所使用的数据量 尝试从 %1$s 获取中继器信息时出错 机主 + 服务密钥 + 正在运行 %1$s + 正在运行 %1$s (%2$s) 版本 软件 联络 支持的 NIPs + 支持 Grasps 接纳费 + 接纳 + 订阅 + 发布 + 付款 %1$s 付款网址 + 目标受众 + 策略 & 链接 + 费用 & 付款 限制 国家 语言 标签 + 话题 + 所有国家和地区 + 所有语言 发布政策 + 隐私政策 + 使用条款 + N/A 该中继的错误和通知 消息长度 订阅 @@ -661,9 +686,28 @@ 最少前缀 最多事件标签 内容长度 + 最大内容长度 + 丢弃旧于 + 最多接受 + 未来 %1$s + %1$s 之前 + %1$s 整 + %1$s 位 + 事件保留 + 内容大小 + 连通性 + 访问控制 最低工作量证明 认证 + 需要认证 支付 + 需要付费 + 最大消息长度 + 最大订阅连接数 + 最多订阅过滤器 + 最大数量(返回事件) + 默认限制(返回事件) + 最大 SubID 长度 Cashu 代币 兑换 发送到打闪钱包 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7893b01aa..e9b34537c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -17,6 +17,8 @@ Could not decrypt the message Group Picture Explicit Content + Relay Notice + Duplicated Post Spam The number of spamming events coming from this relay Impersonation @@ -133,6 +135,7 @@ Relay Address Posts Bytes + Error Errors The number of connection errors in this session Home Feed @@ -144,6 +147,8 @@ Add a User Add a Relay Name + Name (for @tagging) + My @tag name Display Name My display name Ostrich McAwesome @@ -391,6 +396,8 @@ Manual Zap Splits Bookmarks + Default Bookmarks + Your default Bookmarks that many clients support Drafts Private Bookmarks Public Bookmarks @@ -745,28 +752,64 @@ The amount in bytes that was received from this relay, including filters and events An error occurred trying to get relay information from %1$s Owner + Service Key + Running %1$s + Running %1$s (%2$s) Version Software Contact Supported NIPs + Supported Grasps Admission Fees + Admission + Subscription + Publication + Payments %1$s Payments url + Target Audience + Policies & Links + Fees & Payments Limitations Countries Languages Tags + Topics + All Countries + All Languages Posting policy + Privacy Policy + Terms & Conditions + N/A Errors and Notices from this Relay Message length Subscriptions Filters Subscription id length - Minimum prefix - Maximum event tags + Min Prefix + Max Event Tags Content length - Minimum PoW + Max Content Length + Discards older than + Accepts up to + %1$s in the future + %1$s ago + %1$s zeros + %1$s bits + Event Retention + Content Size + Connectivity + Access Control + Min PoW Difficulty Auth + Auth Required Payment + Payment Required + Max Message Length + Max Subscriptions + Max Filters per Sub + Max Limit (Events Returning) + Default Limit (Events Returning) + Max SubID Length Cashu Token Redeem Send to Zap Wallet @@ -1422,4 +1465,5 @@ Delete List Delete Pack + Kinds diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index e66de925e..90ccd7d18 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.mokoResources) } android { @@ -64,12 +65,21 @@ kotlin { implementation(compose.runtime) implementation(compose.material3) implementation(compose.materialIconsExtended) + implementation(compose.components.uiToolingPreview) + + // Image loading (Coil 3 - KMP) + implementation(libs.coil.compose) + implementation(libs.coil.okhttp) // LruCache (KMP-ready) implementation(libs.androidx.collection) // Immutable collections api(libs.kotlinx.collections.immutable) + + // Moko Resources for KMP string resources + api(libs.moko.resources) + api(libs.moko.resources.compose) } } @@ -94,6 +104,9 @@ kotlin { // Desktop-specific Compose implementation(compose.desktop.currentOs) implementation(compose.uiTooling) + + // Secure key storage via OS keychain (macOS/Windows/Linux) + implementation(libs.java.keyring) } } @@ -102,6 +115,10 @@ kotlin { dependencies { // Android-specific Compose tooling implementation(libs.androidx.ui.tooling.preview) + + // Secure key storage via Android Keystore + implementation(libs.androidx.security.crypto.ktx) + implementation(libs.androidx.datastore.preferences) } } @@ -119,3 +136,8 @@ kotlin { } } } + +multiplatformResources { + resourcesPackage.set("com.vitorpamplona.amethyst.commons") + resourcesClassName.set("SharedRes") +} diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt new file mode 100644 index 000000000..55a63141c --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/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.commons.keystorage + +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 + +internal 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, "AndroidKeyStore") + 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, "AndroidKeyStore") + 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, 12) // GCM mode uses 12-byte IV + val data = bytes.copyOfRange(12, bytes.size) + cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv)) + return cipher.doFinal(data) + } +} diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt new file mode 100644 index 000000000..9ee5d1f57 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.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.commons.keystorage + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Android implementation of SecureKeyStorage using EncryptedSharedPreferences + * backed by Android Keystore (AES-256-GCM, hardware-backed when available). + * + * ## Security Features + * + * - **Hardware Security:** Uses Android Keystore (hardware-backed on supported devices with StrongBox) + * - **Encryption:** AES-256-GCM for both keys and values + * - **Key Derivation:** AES-256-SIV for preference keys, AES-256-GCM for values + * - **Application Context:** Uses applicationContext to prevent memory leaks + * - **Auto-backup Disabled:** EncryptedSharedPreferences automatically excluded from cloud backups + * + * **Note:** While the encryption keys are protected by hardware security modules (when available), + * the decrypted private keys returned by [getPrivateKey] are still subject to the String memory + * limitation described in [SecureKeyStorage]. + */ +actual class SecureKeyStorage private actual constructor() { + actual companion object { + private const val PREFS_NAME = "amethyst_secure_keys" + private const val KEY_PREFIX = "privkey_" + + private lateinit var appContext: Context + + /** + * Creates a SecureKeyStorage instance for Android. + * + * @param context Android Context (will use applicationContext to avoid leaks) + * @return SecureKeyStorage instance + * @throws IllegalArgumentException if context is null or not a valid Context + */ + actual fun create(context: Any?): SecureKeyStorage { + require(context is Context) { "Android requires a valid Context" } + appContext = context.applicationContext + return SecureKeyStorage() + } + } + + private val masterKey: MasterKey by lazy { + MasterKey + .Builder(appContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + } + + private val encryptedPrefs by lazy { + EncryptedSharedPreferences.create( + appContext, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + actual suspend fun savePrivateKey( + npub: String, + privKeyHex: String, + ) { + withContext(Dispatchers.IO) { + try { + encryptedPrefs.edit().putString(KEY_PREFIX + npub, privKeyHex).apply() + } catch (e: Exception) { + throw SecureStorageException("Failed to save private key", e) + } + } + } + + actual suspend fun getPrivateKey(npub: String): String? = + withContext(Dispatchers.IO) { + try { + encryptedPrefs.getString(KEY_PREFIX + npub, null) + } catch (e: Exception) { + throw SecureStorageException("Failed to retrieve private key", e) + } + } + + actual suspend fun deletePrivateKey(npub: String): Boolean = + withContext(Dispatchers.IO) { + try { + val key = KEY_PREFIX + npub + val existed = encryptedPrefs.contains(key) + if (existed) { + encryptedPrefs.edit().remove(key).apply() + } + existed + } catch (e: Exception) { + throw SecureStorageException("Failed to delete private key", e) + } + } + + actual suspend fun hasPrivateKey(npub: String): Boolean = + withContext(Dispatchers.IO) { + encryptedPrefs.contains(KEY_PREFIX + npub) + } +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/service/MainThreadChecker.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.android.kt similarity index 73% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/service/MainThreadChecker.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.android.kt index afd0bf22a..9c1e2d7aa 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/service/MainThreadChecker.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.android.kt @@ -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.ammolite.service +package com.vitorpamplona.amethyst.commons.threading import android.os.Looper -import com.vitorpamplona.ammolite.BuildConfig -fun checkNotInMainThread() { - if (BuildConfig.DEBUG && isMainThread()) { +/** + * Android implementation of checkNotInMainThread. + * Uses Looper to detect if running on main thread. + */ +actual fun checkNotInMainThread() { + // BuildConfig check removed - commons doesn't have BuildConfig + // Enable this check in debug builds at the app level if needed + if (isMainThread()) { throw OnMainThreadException("It should not be in the MainThread") } } -fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() - -class OnMainThreadException( - str: String, -) : RuntimeException(str) +private fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.android.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.android.kt new file mode 100644 index 000000000..e97a28225 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.android.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.commons.util + +import java.text.NumberFormat + +actual class PlatformNumberFormatter { + private val formatter = NumberFormat.getInstance() + + actual fun format(value: Long): String = formatter.format(value) +} diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensionTest.kt b/commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensionTest.kt similarity index 100% rename from commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensionTest.kt rename to commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensionTest.kt diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt b/commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt similarity index 96% rename from commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt rename to commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt index d0f9614e7..0c8e5cc73 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt +++ b/commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt @@ -50,7 +50,7 @@ class BlurhashTest { val bmp1 = BlurHashDecoderOld.decode(warmHex, 100, (100 * (1 / aspectRatio)).roundToInt()) val bmp2 = BlurHashDecoder.decodeKeepAspectRatio(warmHex, 100) - assertTrue(bmp1!!.sameAs(bmp2!!)) + assertTrue(bmp1!!.sameAs(bmp2!!.bitmap)) } @Test @@ -60,7 +60,7 @@ class BlurhashTest { val bmp1 = BlurHashDecoderOld.decode(warmHex, 25, (25 * (1 / aspectRatio)).roundToInt()) val bmp2 = BlurHashDecoder.decodeKeepAspectRatio(warmHex, 25) - assertTrue(bmp1!!.sameAs(bmp2!!)) + assertTrue(bmp1!!.sameAs(bmp2!!.bitmap)) } @Test @@ -75,7 +75,7 @@ class BlurhashTest { val bmp1 = BlurHashDecoderOld.decode(testHex, 100, (100 * (1 / aspectRatio)).roundToInt()) val bmp2 = BlurHashDecoder.decodeKeepAspectRatio(testHex, 100) - assertTrue(bmp1!!.sameAs(bmp2!!)) + assertTrue(bmp1!!.sameAs(bmp2!!.bitmap)) } @Test diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserTest.kt b/commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserTest.kt similarity index 100% rename from commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserTest.kt rename to commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserTest.kt diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt b/commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt similarity index 100% rename from commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt rename to commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculatorTest.kt diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt b/commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt similarity index 100% rename from commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt rename to commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt b/commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt similarity index 99% rename from commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt rename to commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt index af10eface..2bf2ea3f6 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt +++ b/commons/src/androidTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists +import junit.framework.TestCase.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -688,7 +689,7 @@ class RichTextParserTest { val state = RichTextParser() .parseText(textToParse, EmptyTagList, null) - org.junit.Assert.assertEquals( + assertEquals( "relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, ⚡\uFE0Fsatscoinsv@getalby.com, miceliomad@miceliomad.github.io/nostr/, zapper.lol, smies.me, baller.hodl", state.urlSet.joinToString(", "), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt index c47beb329..e5a58bb75 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt @@ -33,7 +33,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsAmethystPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt index 1bb9aef79..42171fb11 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt @@ -34,7 +34,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsBtcPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt index 96be5b124..0cdf87955 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsCashuPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt index 6c2543cd2..4ae21b2b3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsCoffeePreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt index d8a9ba332..a435b0450 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsFlowerstrPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt index 70111c982..2d5c0cda6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsFootstrPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt index 9a07e47ae..1a41080b1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt @@ -29,7 +29,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsGamestrPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt index afec134fe..be21273e8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsGrownostrPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt index 3b884cc17..40060fd8d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsLightningPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt index 037b8a7ec..8aa010b16 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsMatePreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt index 24160dde7..6e20b3a34 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsNostrPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt index d1a17353f..cd3231214 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsPlebsPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt index 8e0a23216..1ae1a8764 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsSkullPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt index 2d307df19..03b2a0098 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsTunestrPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt index 668f43b67..21a30eaa8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsWeedPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt index eec30fc12..d76add7ea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun CustomHashTagIconsZapPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt index fe0b9cfc8..659d6fa39 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(Following, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt index 994b6db33..98f7a144a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt @@ -31,7 +31,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(Like, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt index 8e89cc135..d5c8b8a8b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt @@ -31,7 +31,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(Liked, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt index 31349a88b..c4d06b2f2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt @@ -31,7 +31,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(Reply, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt index ffbb79b22..23c452b03 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt @@ -31,7 +31,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(Repost, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt index 2cad31d4a..6ce0a9672 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt @@ -31,7 +31,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(Reposted, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt index e967053d2..87cdcd700 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt @@ -31,7 +31,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(Search, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt index 49923f688..5056c88d7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt @@ -31,7 +31,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(Share, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt index d5169185f..62924cf26 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt @@ -31,7 +31,9 @@ import androidx.compose.ui.graphics.vector.DefaultFillType import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(Zap, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt index 91a111e19..a88a8ae36 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable private fun VectorPreview() { Image(ZapSplit, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt new file mode 100644 index 000000000..6dc292df1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.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.commons.keystorage + +/** + * Secure storage for private keys using platform-specific secure storage mechanisms. + * + * ## Platform Implementations + * + * **Android:** Android Keystore + EncryptedSharedPreferences (AES-256-GCM, StrongBox when available) + * + * **Desktop:** OS native credential managers (macOS Keychain, Windows Credential Manager, Linux Secret Service) + * with encrypted file fallback. + * + * ## Security Considerations + * + * **String Memory Limitation:** Private keys are returned as String (hexadecimal format). Unlike char arrays, + * Kotlin/JVM Strings are immutable and cannot be securely zeroed from memory after use. The private key + * will remain in memory until garbage collected, which may be delayed. This is an inherent limitation of + * the JVM platform. + * + * **Mitigation:** Private keys are only exposed through this API when needed for signing operations. + * They are encrypted at rest on all platforms. For maximum security, minimize the time private keys + * are held in memory and ensure they are dereferenced promptly after use. + * + * **Production Recommendation:** For high-security applications, consider using hardware security modules + * (HSM) or secure enclaves (Android StrongBox, iOS Secure Enclave) that never expose private keys to + * application memory. + * + * Use [SecureKeyStorage.create] factory method to create instances. + */ +expect class SecureKeyStorage private constructor() { + companion object { + /** + * Creates a SecureKeyStorage instance. + * + * @param context Platform-specific context (required on Android, ignored on Desktop) + * @return SecureKeyStorage instance + */ + fun create(context: Any? = null): SecureKeyStorage + } + + /** + * Saves a private key securely for the given npub. + * + * @param npub The public key in npub (Bech32) format + * @param privKeyHex The private key in hexadecimal format + * @throws SecureStorageException if storage operation fails + */ + suspend fun savePrivateKey( + npub: String, + privKeyHex: String, + ) + + /** + * Retrieves a private key for the given npub. + * + * **Security Warning:** The returned String cannot be securely zeroed from memory (JVM limitation). + * Dereference the returned value immediately after use to minimize exposure time. + * + * @param npub The public key in npub (Bech32) format + * @return The private key in hexadecimal format, or null if not found + * @throws SecureStorageException if retrieval operation fails + */ + suspend fun getPrivateKey(npub: String): String? + + /** + * Deletes a private key for the given npub. + * + * @param npub The public key in npub (Bech32) format + * @return true if the key was deleted, false if it didn't exist + * @throws SecureStorageException if deletion operation fails + */ + suspend fun deletePrivateKey(npub: String): Boolean + + /** + * Checks if a private key exists for the given npub. + * + * @param npub The public key in npub (Bech32) format + * @return true if a private key exists, false otherwise + */ + suspend fun hasPrivateKey(npub: String): Boolean +} + +/** + * Exception thrown when secure storage operations fail. + */ +class SecureStorageException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt new file mode 100644 index 000000000..2b2a4510e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.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.commons.model + +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache +import com.vitorpamplona.quartz.utils.DualCase + +/** + * Interface for NIP-47 wallet connect signer state. + * Used by Note.kt for checking NWC payment status. + */ +interface INwcSignerState { + suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? + + suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? + + fun isNIP47Author(pubKey: String?): Boolean +} + +/** + * Hidden content settings for filtering notes. + * Used by Note.isHiddenFor() to check if content should be hidden. + */ +data class LiveHiddenUsers( + val showSensitiveContent: Boolean?, + val hiddenWordsCase: List, + val hiddenUsersHashCodes: Set, + val spammersHashCodes: Set, + // Raw sets for amethyst-specific usage + val hiddenUsers: Set = emptySet(), + val spammers: Set = emptySet(), + val hiddenWords: Set = emptySet(), +) { + fun isUserHidden(userHex: String) = hiddenUsers.contains(userHex) || spammers.contains(userHex) +} + +/** + * Interface for account operations needed by Note.kt. + * Abstracts Android-specific Account class for use in commons. + */ +interface IAccount { + /** NIP-47 wallet connect state for payment verification */ + val nip47SignerState: INwcSignerState + + /** Private zaps decryption cache */ + val privateZapsDecryptionCache: IPrivateZapsDecryptionCache + + /** Current user's profile */ + fun userProfile(): User + + /** Whether account has write permissions */ + fun isWriteable(): Boolean + + /** Current user's public key */ + val pubKey: String + + /** Content filter settings */ + val showSensitiveContent: Boolean? + val hiddenWordsCase: List + val hiddenUsersHashCodes: Set + val spammersHashCodes: Set +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt new file mode 100644 index 000000000..49af94c47 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -0,0 +1,1032 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread +import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji +import com.vitorpamplona.amethyst.commons.util.replace +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.quartz.experimental.bounties.addedRewardValue +import com.vitorpamplona.quartz.experimental.bounties.hasAdditionalReward +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag +import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider +import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag +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.nip28PublicChat.base.IsInPublicChatChannel +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +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.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.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import java.math.BigDecimal + +interface NotesGatherer { + fun removeNote(note: Note) +} + +@Stable +class AddressableNote( + val address: Address, +) : Note(address.toValue()) { + override fun idNote() = toNAddr() + + override fun toNEvent() = toNAddr() + + override fun idDisplayNote() = idNote().toShortDisplay() + + override fun address() = address + + override fun createdAt(): Long? { + val currentEvent = event + if (currentEvent == null) return null + if (currentEvent is PublishedAtProvider) return currentEvent.publishedAt() ?: currentEvent.createdAt + return currentEvent.createdAt + } + + fun dTag(): String = address.dTag + + fun toNAddr() = NAddress.create(address.kind, address.pubKeyHex, address.dTag, relayHintUrl()) + + fun toATag() = ATag(address, relayHintUrl()) +} + +@Stable +open class Note( + val idHex: String, + private val cacheProvider: ICacheProvider? = null, +) : 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 + + var reactions = mapOf>() + private set + + var boosts = listOf() + private set + + var reports = mapOf>() + private set + + var zaps = mapOf() + private set + + var zapsAmount: BigDecimal = BigDecimal.ZERO + + var zapPayments = mapOf() + private set + + var relays = listOf() + private set + + open fun idNote() = toNEvent() + + open fun toNEvent(): String { + val myEvent = event + return if (myEvent is WrappedEvent) { + val host = myEvent.host + if (host != null) { + NEvent.create( + host.id, + host.pubKey, + host.kind, + relayHintUrl(), + ) + } else { + NEvent.create(idHex, author?.pubkeyHex, event?.kind, relayHintUrl()) + } + } else { + NEvent.create(idHex, author?.pubkeyHex, event?.kind, relayHintUrl()) + } + } + + fun relayUrls(): List { + val authorRelay = author?.relayHints() ?: emptyList() + + return authorRelay + relays + } + + fun relayUrlsForReactions(): List { + val authorRelay = author?.inboxRelays() ?: emptyList() + + return authorRelay + relays + } + + fun relayHintUrl(): NormalizedRelayUrl? { + val noteEvent = event + val communityPostRelays = + when (noteEvent) { + is CommunityDefinitionEvent -> noteEvent.relayUrls().ifEmpty { null }?.toSet() + is IsInPublicChatChannel -> cacheProvider?.getAnyChannel(this)?.relays() + else -> null + } + + if (!communityPostRelays.isNullOrEmpty()) return (communityPostRelays as? Collection)?.firstOrNull() + + val currentOutbox = author?.outboxRelays()?.toSet() + + return if (relays.isNotEmpty()) { + if (currentOutbox != null && currentOutbox.isNotEmpty()) { + val relayMatchesOutbox = relays.firstOrNull { it in currentOutbox } + if (relayMatchesOutbox != null) { + return relayMatchesOutbox + } + } + + return relays.firstOrNull() + } else { + currentOutbox?.firstOrNull() ?: author?.latestMetadataRelay + } + } + + fun toNostrUri(): String = "nostr:${toNEvent()}" + + open fun idDisplayNote() = idNote().toShortDisplay() + + open fun address(): Address? = null + + open fun createdAt() = event?.createdAt + + fun isDraft() = event is DraftWrapEvent + + fun loadEvent( + event: Event, + author: User, + replyTo: List, + ) { + if (this.event?.id != event.id) { + this.event = event + this.author = author + this.replyTo = replyTo + + 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 + flowSet?.replies?.invalidateData() + } + } + + fun removeReply(note: Note) { + if (note in replies) { + replies = replies - note + flowSet?.replies?.invalidateData() + } + } + + fun removeBoost(note: Note) { + if (note in boosts) { + boosts = boosts - note + flowSet?.boosts?.invalidateData() + } + } + + fun removeAllChildNotes(): List { + val repliesChanged = replies.isNotEmpty() + val reactionsChanged = reactions.isNotEmpty() + val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() + val boostsChanged = boosts.isNotEmpty() + val reportsChanged = reports.isNotEmpty() + + val toBeRemoved = + replies + + reactions.values.flatten() + + boosts + + reports.values.flatten() + + zaps.keys + + zaps.values.filterNotNull() + + zapPayments.keys + + zapPayments.values.filterNotNull() + + replies = listOf() + reactions = mapOf>() + boosts = listOf() + reports = mapOf>() + zaps = mapOf() + zapPayments = mapOf() + zapsAmount = BigDecimal.ZERO + relays = listOf() + + 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 + } + + fun removeReaction(note: Note) { + val tags = note.event?.tags ?: emptyArray() + val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" + + if (reactions[reaction]?.contains(note) == true) { + reactions[reaction]?.let { + if (note in it) { + val newList = it.minus(note) + if (newList.isEmpty()) { + reactions = reactions.minus(reaction) + } else { + reactions = reactions + Pair(reaction, newList) + } + + flowSet?.reactions?.invalidateData() + } + } + } + } + + fun removeReport(deleteNote: Note) { + val author = deleteNote.author ?: return + + if (reports[author]?.contains(deleteNote) == true) { + reports[author]?.let { + reports = reports + Pair(author, it.minus(deleteNote)) + flowSet?.reports?.invalidateData() + } + } + } + + fun removeZap(note: Note) { + if (zaps[note] != null) { + zaps = zaps.minus(note) + updateZapTotal() + flowSet?.zaps?.invalidateData() + } else if (zaps.containsValue(note)) { + zaps = zaps.filterValues { it != note } + updateZapTotal() + flowSet?.zaps?.invalidateData() + } + } + + fun removeZapPayment(note: Note) { + if (zapPayments.containsKey(note)) { + zapPayments = zapPayments.minus(note) + flowSet?.zaps?.invalidateData() + } else if (zapPayments.containsValue(note)) { + zapPayments = zapPayments.filterValues { it != note } + flowSet?.zaps?.invalidateData() + } + } + + fun addBoost(note: Note) { + if (note !in boosts) { + boosts = boosts + note + flowSet?.boosts?.invalidateData() + } + } + + @Synchronized + private fun innerAddZap( + zapRequest: Note, + zap: Note?, + ): Boolean { + if (zaps[zapRequest] == null) { + zaps = zaps + Pair(zapRequest, zap) + return true + } + + return false + } + + fun addZap( + zapRequest: Note, + zap: Note?, + ) { + if (zaps[zapRequest] == null) { + val inserted = innerAddZap(zapRequest, zap) + if (inserted && zap != null) { + updateZapTotal() + flowSet?.zaps?.invalidateData() + } + } + } + + @Synchronized + private fun innerAddZapPayment( + zapPaymentRequest: Note, + zapPayment: Note?, + ): Boolean { + if (zapPayments[zapPaymentRequest] == null) { + zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) + return true + } + + return false + } + + fun addZapPayment( + zapPaymentRequest: Note, + zapPayment: Note?, + ) { + checkNotInMainThread() + if (zapPayments[zapPaymentRequest] == null) { + val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) + if (inserted) { + flowSet?.zaps?.invalidateData() + } + } + } + + fun addReaction(note: Note) { + val tags = note.event?.tags ?: emptyArray() + val reaction = note.event?.content?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+" + + val listOfAuthors = reactions[reaction] + if (listOfAuthors == null) { + reactions = reactions + Pair(reaction, listOf(note)) + flowSet?.reactions?.invalidateData() + } else if (!listOfAuthors.contains(note)) { + reactions = reactions + Pair(reaction, listOfAuthors + note) + flowSet?.reactions?.invalidateData() + } + } + + fun addReport(note: Note) { + val author = note.author ?: return + + val reportsByAuthor = reports[author] + + if (reportsByAuthor == null) { + reports = reports + Pair(author, listOf(note)) + flowSet?.reports?.invalidateData() + } else if (!reportsByAuthor.contains(note)) { + reports = reports + Pair(author, reportsByAuthor + note) + flowSet?.reports?.invalidateData() + } + } + + @Synchronized + fun addRelaySync(relay: NormalizedRelayUrl) { + if (relay !in relays) { + relays = relays + relay + } + } + + fun hasRelay(relay: NormalizedRelayUrl) = relay in relays + + fun addRelay(relay: NormalizedRelayUrl) { + if (relay !in relays) { + addRelaySync(relay) + flowSet?.relays?.invalidateData() + } + } + + private suspend fun isPaidByCalculation( + zapPayments: List>, + afterTimeInSeconds: Long, + account: IAccount, + ): Boolean { + if (zapPayments.isEmpty()) { + return false + } + + return anyAsync(zapPayments) { next -> + val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent + + if (zapResponseEvent != null) { + val response = account.nip47SignerState.decryptResponse(zapResponseEvent) + if (response != null) { + response is PayInvoiceSuccessResponse && + account.nip47SignerState.isNIP47Author(zapResponseEvent.requestAuthor()) && + zapResponseEvent.createdAt > afterTimeInSeconds + } else { + false + } + } else { + false + } + } + } + + private suspend fun isZappedByCalculation( + option: Int?, + user: User, + afterTimeInSeconds: Long, + account: IAccount, + zapEvents: Map, + ): Boolean { + if (zapEvents.isEmpty()) { + return false + } + + val parallelDecrypt = mutableListOf>() + + zapEvents.forEach { next -> + val zapRequest = next.key.event as LnZapRequestEvent + val zapEvent = next.value?.event as? LnZapEvent + + if (zapEvent != null) { + if (!zapRequest.isPrivateZap()) { + // public events + if (zapRequest.pubKey == user.pubkeyHex && + zapEvent.createdAt > afterTimeInSeconds && + (option == null || option == zapEvent.zappedPollOption()) + ) { + return true + } + } else { + // private events + + // if has already decrypted + val privateZap = account.privateZapsDecryptionCache.cachedPrivateZap(zapRequest) + if (privateZap != null) { + if (privateZap.pubKey == user.pubkeyHex && + zapEvent.createdAt > afterTimeInSeconds && + (option == null || option == zapEvent.zappedPollOption()) + ) { + return true + } + } else { + if (account.isWriteable()) { + parallelDecrypt.add(Pair(zapRequest, zapEvent)) + } + } + } + } + } + + if (parallelDecrypt.isEmpty()) { + return false + } + + return anyAsync(parallelDecrypt) { pair -> + val result = account.privateZapsDecryptionCache.decryptPrivateZap(pair.first) + result?.pubKey == user.pubkeyHex && + pair.second.createdAt > afterTimeInSeconds && + (option == null || option == pair.second.zappedPollOption()) + } + } + + suspend fun isZappedBy( + user: User, + afterTimeInSeconds: Long, + account: IAccount, + ): Boolean { + val first = isZappedByCalculation(null, user, afterTimeInSeconds, account, zaps) + if (first) return true + if (account.userProfile() == user) { + return isPaidByCalculation(zapPayments.toList(), afterTimeInSeconds, account) + } + return false + } + + suspend fun isZappedBy( + option: Int?, + user: User, + afterTimeInSeconds: Long, + account: IAccount, + ): Boolean = isZappedByCalculation(option, user, afterTimeInSeconds, account, zaps) + + fun getReactionBy(user: User): String? = + reactions.firstNotNullOfOrNull { + if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) { + it.key + } else { + null + } + } + + fun isBoostedBy(user: User): Boolean = boosts.any { it.author?.pubkeyHex == user.pubkeyHex } + + fun hasReportsBy(user: User): Boolean = reports[user]?.isNotEmpty() ?: false + + fun countReportAuthorsBy(users: Set): Int = reports.count { it.key.pubkeyHex in users } + + fun reportsBy(users: Set): List = + reports + .mapNotNull { + if (it.key.pubkeyHex in users) { + it.value + } else { + null + } + }.flatten() + + private fun updateZapTotal() { + var sumOfAmounts = BigDecimal.ZERO + + // Regular Zap Receipts + zaps.forEach { + val noteEvent = it.value?.event + if (noteEvent is LnZapEvent) { + sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO + } + } + + zapsAmount = sumOfAmounts + } + + private suspend fun zappedAmountCalculation( + startAmount: BigDecimal, + paidInvoiceSet: LinkedHashSet, + zapPayments: List>, + signerState: INwcSignerState, + ): BigDecimal { + if (zapPayments.isEmpty()) { + return startAmount + } + + var output: BigDecimal = startAmount + + launchAndWaitAll(zapPayments) { next -> + val result = + processZapAmountFromResponse( + next.first, + next.second, + signerState, + ) + + if (result != null && !paidInvoiceSet.contains(result.invoice)) { + paidInvoiceSet.add(result.invoice) + output = output.add(result.amount) + } + } + + return output + } + + private suspend fun processZapAmountFromResponse( + paymentRequest: Note, + paymentResponse: Note?, + signerState: INwcSignerState, + ): InvoiceAmount? { + val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent + val nwcResponse = paymentResponse?.event as? LnZapPaymentResponseEvent + + return if (nwcRequest != null && nwcResponse != null) { + processZapAmountFromResponse( + nwcRequest, + nwcResponse, + signerState, + ) + } else { + null + } + } + + class InvoiceAmount( + val invoice: String, + val amount: BigDecimal, + ) + + private suspend fun processZapAmountFromResponse( + nwcRequest: LnZapPaymentRequestEvent, + nwcResponse: LnZapPaymentResponseEvent, + signerState: INwcSignerState, + ): InvoiceAmount? { + // if we can decrypt the reply + return signerState.decryptResponse(nwcResponse)?.let { noteEvent -> + // if it is a sucess + if (noteEvent is PayInvoiceSuccessResponse) { + // if we can decrypt the 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 { + LnInvoiceUtil.getAmountInSats(invoice) + } catch (e: java.lang.Exception) { + if (e is CancellationException) throw e + null + } + + // avoid double counting + if (amount != null) { + InvoiceAmount(invoice, amount) + } else { + null + } + } else { + null + } + } else { + null + } + } + } + + suspend fun zappedAmountWithNWCPayments(signerState: INwcSignerState): BigDecimal { + if (zapPayments.isEmpty()) { + return zapsAmount + } + + val invoiceSet = LinkedHashSet(zaps.size + zapPayments.size) + zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } } + + return zappedAmountCalculation( + zapsAmount, + invoiceSet, + zapPayments.toList(), + signerState, + ) + } + + fun hasReport( + loggedIn: User, + type: ReportType, + ): Boolean = + reports[loggedIn]?.firstOrNull { + it.event is ReportEvent && + (it.event as ReportEvent).reportedAuthor().any { it.type == type } + } != null + + fun hasPledgeBy(user: User): Boolean = + replies + .filter { it.event?.hasAdditionalReward() ?: false } + .any { + val pledgeValue = + try { + BigDecimal(it.event?.content) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + // do nothing if it can't convert to bigdecimal + } + + pledgeValue != null && it.author == user + } + + fun pledgedAmountByOthers(): BigDecimal = replies.sumOf { it.event?.addedRewardValue() ?: BigDecimal.ZERO } + + fun hasAnyReports(): Boolean { + val dayAgo = TimeUtils.oneDayAgo() + + if (reports.isNotEmpty()) return true + + return author?.reportsOrNull()?.hasReportNewerThan(dayAgo) ?: false + } + + fun isNewThread(): Boolean = + ( + event is RepostEvent || + event is GenericRepostEvent || + replyTo == null || + replyTo?.size == 0 + ) && + event !is ChannelMessageEvent && + event !is LiveActivitiesChatMessageEvent + + fun hasZapped(loggedIn: User): Boolean = zaps.any { it.key.author == loggedIn } + + fun hasReacted( + loggedIn: User, + content: String, + ): Boolean = allReactionsOfContentByAuthor(loggedIn, content).isNotEmpty() + + fun allReactionsOfContentByAuthor( + loggedIn: User, + content: String, + ): List = reactions[content]?.filter { it.author == loggedIn } ?: emptyList() + + fun allReactionsByAuthor(loggedIn: User): List = reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } + + fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { + val fiveMinsAgo = TimeUtils.fiveMinutesAgo() + return boosts.any { + it.author == loggedIn && (it.createdAt() ?: 0L) > fiveMinsAgo + } + } + + fun hasBoostedInTheLast5Minutes(loggedIn: HexKey): Boolean { + val fiveMinsAgo = TimeUtils.fiveMinutesAgo() + return boosts.any { + (it.createdAt() ?: 0L) > fiveMinsAgo && it.author?.pubkeyHex == loggedIn + } + } + + fun boostedBy(loggedIn: User): List = boosts.filter { it.author == loggedIn } + + fun moveAllReferencesTo(note: AddressableNote) { + // migrates these comments to a new version + replies.forEach { + note.addReply(it) + it.replyTo = it.replyTo?.replace(this, note) + } + reactions.forEach { + it.value.forEach { + note.addReaction(it) + it.replyTo = it.replyTo?.replace(this, note) + } + } + boosts.forEach { + note.addBoost(it) + it.replyTo = it.replyTo?.replace(this, note) + } + reports.forEach { + it.value.forEach { + note.addReport(it) + it.replyTo = it.replyTo?.replace(this, note) + } + } + zaps.forEach { + note.addZap(it.key, it.value) + it.key.replyTo = it.key.replyTo?.replace(this, note) + it.value?.replyTo = it.value?.replyTo?.replace(this, note) + } + + replyTo = null + replies = emptyList() + reactions = emptyMap() + boosts = emptyList() + reports = emptyMap() + zaps = emptyMap() + zapsAmount = BigDecimal.ZERO + } + + fun isHiddenFor(accountChoices: LiveHiddenUsers): Boolean { + val thisEvent = event ?: return false + val hash = thisEvent.pubKey.hashCode() + + // if the author is hidden by spam or blocked + if (accountChoices.hiddenUsersHashCodes.contains(hash) || + accountChoices.spammersHashCodes.contains(hash) + ) { + return true + } + + // if the post is sensitive and the user doesn't want to see sensitive content + if (accountChoices.showSensitiveContent == false && thisEvent.isSensitiveOrNSFW()) { + return true + } + + // if this is a repost, consider the inner event. + if ( + thisEvent is GenericRepostEvent || + thisEvent is RepostEvent || + thisEvent is CommunityPostApprovalEvent + ) { + if (replyTo?.lastOrNull()?.isHiddenFor(accountChoices) == true) { + return true + } + } + + if (accountChoices.hiddenWordsCase.isNotEmpty()) { + if (thisEvent is BaseThreadedEvent && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) { + return true + } + + if (thisEvent is CommentEvent) { + thisEvent.isScoped { it.containsAny(accountChoices.hiddenWordsCase) } + } + + if (thisEvent.anyHashTag { it.containsAny(accountChoices.hiddenWordsCase) }) { + return true + } + + if (author?.containsAny(accountChoices.hiddenWordsCase) == true) return true + } + + return false + } + + var flowSet: NoteFlowSet? = null + + @Synchronized + fun createOrDestroyFlowSync(create: Boolean) { + if (create) { + if (flowSet == null) { + flowSet = NoteFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet = null + } + } + } + + fun flow(): NoteFlowSet { + if (flowSet == null) { + createOrDestroyFlowSync(true) + } + return flowSet!! + } + + fun clearFlow() { + if (flowSet != null && flowSet?.isInUse() == false) { + createOrDestroyFlowSync(false) + } + } + + fun toETag(): ETag { + val noteEvent = event + return if (noteEvent != null) { + ETag(noteEvent.id, relayHintUrl(), noteEvent.pubKey) + } else { + ETag(idHex, relayHintUrl(), author?.pubkeyHex) + } + } + + fun toEId(): EventReference { + val noteEvent = event + return if (noteEvent != null) { + // uses the confirmed event id if available + EventReference(noteEvent.id, noteEvent.pubKey, relayHintUrl()) + } else { + EventReference(idHex, author?.pubkeyHex, relayHintUrl()) + } + } + + 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 + return if (noteEvent != null) { + MarkedETag(noteEvent.id, relayHintUrl(), marker, noteEvent.pubKey) + } else { + MarkedETag(idHex, relayHintUrl(), marker, author?.pubkeyHex) + } + } +} + +@Stable +class NoteFlowSet( + u: Note, +) { + // Observers line up here. + 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() = + metadata.stateFlow.flatMapLatest { + it.note.author + ?.flow() + ?.metadata + ?.stateFlow ?: MutableStateFlow(null) + } + + fun isInUse(): Boolean = + metadata.hasObservers() || + reports.hasObservers() || + relays.hasObservers() || + reactions.hasObservers() || + boosts.hasObservers() || + replies.hasObservers() || + zaps.hasObservers() || + ots.hasObservers() || + edits.hasObservers() +} + +@Stable +class NoteBundledRefresherFlow( + val note: Note, +) { + // Refreshes observers in batches. + val stateFlow = MutableStateFlow(NoteState(note)) + + fun invalidateData() { + stateFlow.tryEmit(NoteState(note)) + } + + fun hasObservers() = stateFlow.subscriptionCount.value > 0 +} + +@Immutable +class NoteState( + val note: Note, +) + +fun List.eventIdSet() = mapNotNullTo(mutableSetOf()) { it.event?.id } + +fun Array.events() = mapNotNull { it.note.event as? T } + +fun List.events() = mapNotNull { it.event as? T } + +fun List.updateFlow(): Flow> = + if (this.isEmpty()) { + MutableStateFlow(emptyList()) + } else { + combine( + flows = this.map { it.flow().metadata.stateFlow }, + ) { + it.events() + } + } + +public inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolean { + if (this is Collection && isEmpty()) return false + for (note in this) { + val noteEvent = note.event as? T + if (noteEvent != null && predicate(noteEvent)) return true + } + return false +} + +public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean { + if (this is Collection && isEmpty()) return false + for (note in this) { + val noteEvent = note.event + if (noteEvent != null && predicate(noteEvent)) return true + } + return false +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt new file mode 100644 index 000000000..287992db5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -0,0 +1,340 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache +import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.quartz.lightning.Lud06 +import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists +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.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +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 java.math.BigDecimal + +interface UserDependencies + +@Stable +class User( + val pubkeyHex: String, + val nip65RelayListNote: Note, + val dmRelayListNote: Note, + private val cacheProvider: ICacheProvider? = null, +) { + private var reports: UserReportCache? = null + private var cards: UserCardsCache? = null + + // private var deps = ScatterMap, UserDependencies>() + + var info: UserMetadata? = null + + var latestMetadata: MetadataEvent? = null + var latestMetadataRelay: NormalizedRelayUrl? = null + var latestContactList: ContactListEvent? = null + + var zaps = mapOf() + private set + + var relaysBeingUsed = mapOf() + private set + + var flowSet: UserFlowSet? = null + + fun pubkey() = Hex.decode(pubkeyHex) + + fun pubkeyNpub() = pubkey().toNpub() + + fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay() + + fun dmInboxRelayList() = dmRelayListNote.event as? ChatMessageRelayListEvent + + fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent + + fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) + + fun outboxRelays() = authorRelayList()?.writeRelaysNorm() + + fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay) + + fun inboxRelays() = authorRelayList()?.readRelaysNorm() + + fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays() + + fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: latestMetadataRelay + + fun toPTag() = PTag(pubkeyHex, bestRelayHint()) + + fun toNostrUri() = "nostr:${toNProfile()}" + + fun toBestShortFirstName(): String { + val fullName = toBestDisplayName() + + val names = fullName.split(' ') + + val firstName = + if (names[0].length <= 3) { + // too short. Remove Dr. + "${names[0]} ${names.getOrNull(1) ?: ""}" + } else { + names[0] + } + + return firstName + } + + fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex() + + fun nip05(): String? = info?.nip05 + + fun profilePicture(): String? = info?.picture + + fun updateContactList(event: ContactListEvent) { + if (event.id == latestContactList?.id) return + + val oldContactListEvent = latestContactList + latestContactList = event + + // Update following of the current user + flowSet?.follows?.invalidateData() + + // Update Followers of the past user list + // Update Followers of the new contact list + (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { + (cacheProvider?.getUserIfExists(it) as? User) + ?.flowSet + ?.followers + ?.invalidateData() + } + (latestContactList)?.unverifiedFollowKeySet()?.forEach { + (cacheProvider?.getUserIfExists(it) as? User) + ?.flowSet + ?.followers + ?.invalidateData() + } + } + + fun addZap( + zapRequest: Note, + zap: Note?, + ) { + if (zaps[zapRequest] == null) { + zaps = zaps + Pair(zapRequest, zap) + flowSet?.zaps?.invalidateData() + } + } + + fun removeZap(zapRequestOrZapEvent: Note) { + if (zaps.containsKey(zapRequestOrZapEvent)) { + zaps = zaps.minus(zapRequestOrZapEvent) + flowSet?.zaps?.invalidateData() + } else if (zaps.containsValue(zapRequestOrZapEvent)) { + zaps = zaps.filter { it.value != zapRequestOrZapEvent } + flowSet?.zaps?.invalidateData() + } + } + + fun zappedAmount(): BigDecimal { + var amount = BigDecimal.ZERO + zaps.forEach { + val itemValue = (it.value?.event as? LnZapEvent)?.amount + if (itemValue != null) { + amount += itemValue + } + } + + return amount + } + + fun addRelayBeingUsed( + relay: NormalizedRelayUrl, + eventTime: Long, + ) { + val here = relaysBeingUsed[relay] + if (here == null) { + relaysBeingUsed = relaysBeingUsed + Pair(relay, RelayInfo(relay, eventTime, 1)) + } else { + if (eventTime > here.lastEvent) { + here.lastEvent = eventTime + } + here.counter++ + } + + flowSet?.usedRelays?.invalidateData() + } + + fun updateUserInfo( + newUserInfo: UserMetadata, + latestMetadata: MetadataEvent, + ) { + info = newUserInfo + info?.tags = latestMetadata.tags.toImmutableListOfLists() + info?.cleanBlankNames() + + if (newUserInfo.lud16.isNullOrBlank()) { + info?.lud06?.let { + if (it.lowercase().startsWith("lnurl")) { + info?.lud16 = Lud06().toLud16(it) + } + } + } + + flowSet?.metadata?.invalidateData() + } + + fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false + + fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size + + fun transientFollowerCount(): Int = + cacheProvider?.countUsers { _, it -> + (it as? User)?.latestContactList?.isTaggedUser(pubkeyHex) ?: false + } ?: 0 + + fun reportsOrNull(): UserReportCache? = reports + + fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it } + + // fun reportsOrNull(): UserReports? = deps[UserReports::class] as? UserReports + + // fun reports(): UserReports = deps.getOrPut(UserReports::class) { UserReports() } as UserReports + + fun cardsOrNull(): UserCardsCache? = cards + + fun cards(): UserCardsCache = cards ?: UserCardsCache().also { cards = it } + + fun containsAny(hiddenWordsCase: List): Boolean { + if (hiddenWordsCase.isEmpty()) return false + + if (toBestDisplayName().containsAny(hiddenWordsCase)) { + return true + } + + if (profilePicture()?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.banner?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.about?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.lud06?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.lud16?.containsAny(hiddenWordsCase) == true) { + return true + } + + if (info?.nip05?.containsAny(hiddenWordsCase) == true) { + return true + } + + return false + } + + fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false + + @Synchronized + fun createOrDestroyFlowSync(create: Boolean) { + if (create) { + if (flowSet == null) { + flowSet = UserFlowSet(this) + } + } else { + if (flowSet != null && flowSet?.isInUse() == false) { + flowSet = null + } + } + } + + fun flow(): UserFlowSet { + if (flowSet == null) { + createOrDestroyFlowSync(true) + } + return flowSet!! + } + + fun clearFlow() { + if (flowSet != null && flowSet?.isInUse() == false) { + createOrDestroyFlowSync(false) + } + } +} + +@Stable +class UserFlowSet( + u: User, +) { + // Observers line up here. + val metadata = UserBundledRefresherFlow(u) + val follows = UserBundledRefresherFlow(u) + val followers = UserBundledRefresherFlow(u) + val usedRelays = UserBundledRefresherFlow(u) + val zaps = UserBundledRefresherFlow(u) + val statuses = UserBundledRefresherFlow(u) + + fun isInUse(): Boolean = + metadata.hasObservers() || + follows.hasObservers() || + followers.hasObservers() || + usedRelays.hasObservers() || + zaps.hasObservers() || + statuses.hasObservers() +} + +@Immutable +data class RelayInfo( + val url: NormalizedRelayUrl, + var lastEvent: Long, + var counter: Long, +) + +// Re-export from commons.state for backwards compatibility +typealias UserBundledRefresherFlow = com.vitorpamplona.amethyst.commons.state.UserMetadataState +typealias UserState = com.vitorpamplona.amethyst.commons.state.UserState + +fun Set.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } + +fun Set.toSortedHexes() = map { it.pubkeyHex }.sorted() + +fun List.toHexes() = map { it.pubkeyHex } + +fun List.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex } + +fun List.toSortedHexes() = map { it.pubkeyHex }.sorted() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.kt new file mode 100644 index 000000000..ab9fb6a6d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/ICacheProvider.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.commons.model.cache + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Cache provider interface for accessing cached Notes, Users, and Channels. + * + * This abstraction allows Note and User models to access the cache without + * direct coupling to LocalCache singleton. Platform-specific implementations + * (Android LocalCache, Desktop DesktopLocalCache) implement this interface. + * + * Benefits: + * - Dependency injection instead of singleton coupling + * - Testable (can mock for unit tests) + * - Platform-agnostic model layer + */ +interface ICacheProvider { + /** + * Gets a channel by Note reference. + * Used for resolving relay hints for channel messages. + * + * @param note The note to look up channel for + * @return The channel if found, null otherwise + */ + fun getAnyChannel(note: Any?): IChannel? + + /** + * Gets a User by public key hex. + * Used for updating follower counts and user relationships. + * + * @param pubkey The user's public key in hex format + * @return The User if exists in cache, null otherwise + */ + fun getUserIfExists(pubkey: HexKey): Any? + + /** + * Counts users matching a predicate. + * Used for calculating follower counts. + * + * @param predicate Filter function for counting users + * @return Count of users matching the predicate + */ + fun countUsers(predicate: (String, Any) -> Boolean): Int +} + +/** + * Minimal channel interface for relay resolution. + * Full channel implementations (PublicChatChannel, LiveActivitiesChannel) + * implement this interface. + */ +interface IChannel { + /** + * Gets the relay URLs for this channel. + * @return List of relay URLs or null if none configured + */ + fun relays(): List? +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/FollowAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/FollowAction.kt new file mode 100644 index 000000000..84243a592 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/FollowAction.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.commons.model.nip02FollowList + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.ReadWrite +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag + +/** + * Shared action for following/unfollowing users. + * Creates NIP-02 ContactList events (kind 3). + */ +object FollowAction { + /** + * Follows a user by creating an updated ContactListEvent. + * + * @param pubKeyHex The hex public key of the user to follow + * @param signer The NostrSigner to sign the event + * @param currentContactList The current contact list event (if any) + * @return Signed ContactListEvent with the user added + * @throws IllegalStateException if signer is not writeable + */ + suspend fun follow( + pubKeyHex: String, + signer: NostrSigner, + currentContactList: ContactListEvent? = null, + ): ContactListEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot follow: signer is not writeable") + } + + val updatedEvent = + if (currentContactList != null) { + // Add to existing contact list + ContactListEvent.followUser( + earlierVersion = currentContactList, + pubKeyHex = pubKeyHex, + signer = signer, + ) + } else { + // Create new contact list from scratch + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(pubKeyHex)), + relayUse = emptyMap(), + signer = signer, + ) + } + + return updatedEvent + } + + /** + * Unfollows a user by creating an updated ContactListEvent. + * + * @param pubKeyHex The hex public key of the user to unfollow + * @param signer The NostrSigner to sign the event + * @param currentContactList The current contact list event (required for unfollow) + * @return Signed ContactListEvent with the user removed + * @throws IllegalStateException if signer is not writeable + * @throws IllegalArgumentException if currentContactList is null + */ + suspend fun unfollow( + pubKeyHex: String, + signer: NostrSigner, + currentContactList: ContactListEvent?, + ): ContactListEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot unfollow: signer is not writeable") + } + + requireNotNull(currentContactList) { + "Cannot unfollow: current contact list is required" + } + + // Remove from existing contact list + val updatedEvent = + ContactListEvent.unfollowUser( + earlierVersion = currentContactList, + pubKeyHex = pubKeyHex, + signer = signer, + ) + + return updatedEvent + } + + /** + * Checks if a user is currently followed in the contact list. + * + * @param pubKeyHex The hex public key to check + * @param currentContactList The current contact list event (if any) + * @return true if the user is followed, false otherwise + */ + fun isFollowing( + pubKeyHex: String, + currentContactList: ContactListEvent?, + ): Boolean { + if (currentContactList == null) return false + + // Check if the pubKeyHex is in the contact list's p-tags + return currentContactList.tags.any { tag -> + tag.size >= 2 && tag[0] == "p" && tag[1] == pubKeyHex + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip10TextNotes/PublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip10TextNotes/PublishAction.kt new file mode 100644 index 000000000..8ab3ce094 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip10TextNotes/PublishAction.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.commons.model.nip10TextNotes + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +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.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findURLs + +/** + * Shared action for publishing new text notes. + * Handles replies, hashtags, and URL references. + */ +object PublishAction { + /** + * Publishes a text note (NIP-01 kind 1). + * + * @param content The text content of the note + * @param signer The NostrSigner to sign the event + * @param replyTo Optional event to reply to (adds e-tag and p-tag) + * @return Signed TextNoteEvent ready to broadcast + * @throws IllegalStateException if signer is not writeable + */ + suspend fun publishTextNote( + content: String, + signer: NostrSigner, + replyTo: Event? = null, + ): TextNoteEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot publish: signer is not writeable") + } + + val template = + TextNoteEvent.build(content) { + // If replying, add e-tag and p-tag + if (replyTo != null) { + val etag = ETag(replyTo.id) + etag.relay = null + etag.author = replyTo.pubKey + eTag(etag) + pTag(PTag(replyTo.pubKey, relayHint = null)) + } + + // Extract hashtags and URLs from content + hashtags(findHashtags(content)) + references(findURLs(content)) + } + + return signer.sign(template) + } + + /** + * Publishes a reply to an existing note. + */ + suspend fun publishReply( + content: String, + replyTo: Event, + signer: NostrSigner, + ): TextNoteEvent = publishTextNote(content, signer, replyTo) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt new file mode 100644 index 000000000..0f68e4f6c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.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.commons.model.nip18Reposts + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent + +/** + * Shared action for reposting (boosting) events. + * Supports both NIP-18 reposts (kind 6 for kind 1) and generic reposts (kind 16 for other kinds). + */ +object RepostAction { + /** + * Creates a signed repost event. + * + * @param event The event to repost + * @param signer The NostrSigner to sign the event + * @param noteHint Optional relay hint where the note was seen + * @param authorHint Optional relay hint for the author's outbox + * @return Signed repost event ready to broadcast + * @throws IllegalStateException if signer is not writeable + */ + suspend fun repost( + event: Event, + signer: NostrSigner, + noteHint: String? = null, + authorHint: String? = null, + ): Event { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot repost: signer is not writeable") + } + + // Normalize relay hints + val normalizedNoteHint = noteHint?.let { RelayUrlNormalizer.normalizeOrNull(it) } + val normalizedAuthorHint = authorHint?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + // Use NIP-18 RepostEvent (kind 6) for text notes (kind 1) + // Use GenericRepostEvent (kind 16) for all other kinds + val template = + if (event.kind == 1) { + RepostEvent.build(event, normalizedNoteHint, normalizedAuthorHint) + } else { + GenericRepostEvent.build(event, normalizedNoteHint, normalizedAuthorHint) + } + + return signer.sign(template) + } + + /** + * Android-compatible overload: Repost a note with full validation. + * + * This accepts Note directly and handles all validation including + * duplicate checking (5-minute window) and writeability checks. + * + * @param note The note to repost + * @param signer The NostrSigner to sign the event + * @return Signed repost event, or null if validation fails + */ + suspend fun repost( + note: Note, + signer: NostrSigner, + ): Event? { + // All validation in commons + if (!signer.isWriteable()) return null + if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) return null + + val noteEvent = note.event ?: return null + val noteHint = note.relayHintUrl()?.url + val authorHint = note.author?.bestRelayHint()?.url + + return repost(noteEvent, signer, noteHint, authorHint) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt new file mode 100644 index 000000000..19cbb4d3e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt @@ -0,0 +1,193 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.model.nip25Reactions + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +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 + +/** + * Shared action for reacting to events (likes, emoji reactions). + * Supports public reactions, custom emoji reactions, and NIP-17 private group reactions. + * + * Full-featured version extracted from Android for maximum code reuse across platforms. + */ +object ReactionAction { + /** + * Creates a signed reaction event. + * + * @param event The event to react to + * @param reaction The reaction content (e.g., "+", "❤️", ":custom_emoji:") + * @param signer The NostrSigner to sign the event + * @param relayHint Optional relay hint URL where the original event was seen + * @return Signed ReactionEvent ready to broadcast + * @throws IllegalStateException if signer is not writeable + */ + suspend fun reactTo( + event: Event, + reaction: String, + signer: NostrSigner, + relayHint: String? = null, + ): ReactionEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot react: signer is not writeable") + } + + val normalizedRelayHint = relayHint?.let { RelayUrlNormalizer.normalizeOrNull(it) } + val eventHint = EventHintBundle(event, normalizedRelayHint) + + // Handle custom emoji reactions (format: ":emoji_name:") + val template = + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + ReactionEvent.build(emojiUrl, eventHint) + } else { + // Fallback to text if emoji decode fails + ReactionEvent.build(reaction, eventHint) + } + } else { + ReactionEvent.build(reaction, eventHint) + } + + return signer.sign(template) + } + + /** + * Creates a "like" reaction ("+"). + */ + suspend fun like( + event: Event, + signer: NostrSigner, + relayHint: String? = null, + ): ReactionEvent = reactTo(event, "+", signer, relayHint) + + /** + * Advanced: React to an event with support for NIP-17 private groups. + * + * This method handles both public and private group reactions: + * - For NIP17Group events: Creates private reactions within the group + * - For regular events: Creates public reactions + * + * @param event The event to react to + * @param eventHint EventHintBundle with relay information + * @param reaction The reaction content (e.g., "+", "❤️", ":custom_emoji:") + * @param signer The NostrSigner to sign the event + * @param onPublic Callback for public reactions (returns signed ReactionEvent) + * @param onPrivate Callback for private group reactions (returns NIP17Factory.Result) + */ + suspend fun reactToWithGroupSupport( + event: Event, + eventHint: EventHintBundle, + reaction: String, + signer: NostrSigner, + onPublic: suspend (ReactionEvent) -> Unit, + onPrivate: suspend (NIP17Factory.Result) -> Unit, + ) { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot react: signer is not writeable") + } + + // Check if this is a NIP-17 private group event + if (event is NIP17Group) { + val users = event.groupMembers().toList() + + // Handle custom emoji reactions in groups + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + onPrivate( + NIP17Factory().createReactionWithinGroup( + emojiUrl = emojiUrl, + originalNote = eventHint, + to = users, + signer = signer, + ), + ) + return + } + } + + // Regular text reaction in group + onPrivate( + NIP17Factory().createReactionWithinGroup( + content = reaction, + originalNote = eventHint, + to = users, + signer = signer, + ), + ) + } else { + // Public reaction + val template = + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + ReactionEvent.build(emojiUrl, eventHint) + } else { + ReactionEvent.build(reaction, eventHint) + } + } else { + ReactionEvent.build(reaction, eventHint) + } + + onPublic(signer.sign(template)) + } + } + + /** + * Android-compatible overload: React to a note with full validation. + * + * This accepts Note/User directly and handles all validation including + * duplicate checking and writeability checks. + * + * @param note The note to react to + * @param reaction The reaction content + * @param by The user reacting + * @param signer The NostrSigner to sign the event + * @param onPublic Callback for public reactions + * @param onPrivate Callback for private group reactions + */ + suspend fun reactTo( + note: Note, + reaction: String, + by: User, + signer: NostrSigner, + onPublic: (ReactionEvent) -> Unit, + onPrivate: suspend (NIP17Factory.Result) -> Unit, + ) { + // All validation in commons + if (!signer.isWriteable()) return + if (note.hasReacted(by, reaction)) return + + val noteEvent = note.event ?: return + val eventHint = note.toEventHint() ?: return + + reactToWithGroupSupport(noteEvent, eventHint, reaction, signer, onPublic, onPrivate) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/UserReportCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportCache.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/UserReportCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportCache.kt index c6064dfb8..97b3cdc6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip56Reports/UserReportCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip56Reports/UserReportCache.kt @@ -18,12 +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.model.nip56Reports +package com.vitorpamplona.amethyst.commons.model.nip56Reports -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.UserDependencies -import com.vitorpamplona.amethyst.service.relays.EOSERelayList +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserDependencies +import com.vitorpamplona.amethyst.commons.relays.EOSERelayList import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -34,11 +34,8 @@ import kotlinx.coroutines.flow.update class UserReportCache : UserDependencies { val receivedReportsByAuthor = MutableStateFlow(mapOf>()) - /** - * 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: EOSERelayList = EOSERelayList() + /** Tracks EOSE (End Of Stored Events) for relay subscriptions */ + val latestEOSEs = EOSERelayList() fun addReport(note: Note) { val author = note.author ?: return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/TrustProviderListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/TrustProviderListState.kt new file mode 100644 index 000000000..67410df63 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/TrustProviderListState.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.amethyst.commons.model.trustedAssertions + +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag +import kotlinx.coroutines.flow.StateFlow + +/** + * Interface for trust provider list state. + * Used by UserCardsCache for accessing user rank and follower count providers. + */ +interface TrustProviderListState { + val liveUserRankProvider: StateFlow + val liveUserFollowerCount: StateFlow +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt index abb6edf9a..f8a5b4552 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/trustedAssertions/UserCardsCache.kt @@ -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.model.trustedAssertions +package com.vitorpamplona.amethyst.commons.model.trustedAssertions -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.UserDependencies -import com.vitorpamplona.amethyst.service.relays.EOSERelayList +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserDependencies +import com.vitorpamplona.amethyst.commons.relays.EOSERelayList +import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -32,16 +33,12 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import java.text.NumberFormat class UserCardsCache : UserDependencies { val receivedCards = MutableStateFlow(mapOf()) - /** - * 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: EOSERelayList = EOSERelayList() + /** Tracks EOSE (End Of Stored Events) for relay subscriptions */ + val latestEOSEs = EOSERelayList() fun addCard(note: AddressableNote) { val author = note.author ?: return @@ -109,7 +106,7 @@ class UserCardsCache : UserDependencies { (it?.note?.event as? ContactCardEvent)?.rank() }.flowOn(Dispatchers.IO) - val formatter = NumberFormat.getInstance() + private val formatter = PlatformNumberFormatter() fun followerCountStrFlow(trustProviderList: TrustProviderListState) = combineTransform(receivedCards, trustProviderList.liveUserFollowerCount) { cards, provider -> @@ -137,7 +134,7 @@ class UserCardsCache : UserDependencies { val value = (it?.note?.event as? ContactCardEvent)?.followerCount() if (value != null && value > 0) { - formatter.format(value) + formatter.format(value.toLong()) } else { "--" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.kt new file mode 100644 index 000000000..84f16502c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/HtmlCharsetParser.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.commons.preview + +import java.nio.charset.Charset + +object HtmlCharsetParser { + val ATTRIBUTE_VALUE_CHARSET = "charset" + val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv" + val CONTENT = "content" + + private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""") + + fun detectCharset(bodyBytes: ByteArray): Charset { + // try to detect charset from meta tags parsed from first 1024 bytes of body + val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8")) + val metaTags = MetaTagsParser.parse(firstPart) + metaTags.forEach { meta -> + val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET) + if (charsetAttr.isNotEmpty()) { + runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let { + return it + } + } + if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") { + RE_CONTENT_TYPE_CHARSET + .find(meta.attr(CONTENT)) + ?.let { + runCatching { Charset.forName(it.groupValues[1]) }.getOrNull() + }?.let { + return it + } + } + } + // defaults to UTF-8 + return Charset.forName("utf-8") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt index cec53b311..6543b3d08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/OpenGraphParser.kt @@ -18,9 +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.service.previews - -import com.vitorpamplona.amethyst.commons.preview.MetaTag +package com.vitorpamplona.amethyst.commons.preview class OpenGraphParser { class Result( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt index e2f483482..7b28bda05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlInfoItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlInfoItem.kt @@ -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.service.previews +package com.vitorpamplona.amethyst.commons.preview import androidx.compose.runtime.Immutable import java.net.URL @@ -31,7 +31,7 @@ class UrlInfoItem( val image: String = "", val mimeType: String, ) { - val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull() + val verifiedUrl = runCatching { URL(url) }.getOrNull() val imageUrlFullPath = if (image.startsWith("/")) { URL(verifiedUrl, image).toString() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.kt new file mode 100644 index 000000000..c8ad7b0f6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/EOSERelayList.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.commons.relays + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +typealias SincePerRelayMap = MutableMap + +/** + * Tracks EOSE (End Of Stored Events) timestamps for relay subscriptions. + * Used by UserCardsCache and similar classes to manage relay subscription state. + */ +class EOSERelayList { + var relayList: SincePerRelayMap = mutableMapOf() + + fun addOrUpdate( + relayUrl: NormalizedRelayUrl, + time: Long, + ) { + val eose = relayList[relayUrl] + if (eose == null) { + relayList[relayUrl] = MutableTime(time) + } else { + eose.updateIfNewer(time) + } + } + + fun clear() { + relayList = mutableMapOf() + } + + fun since() = relayList + + fun newEose( + relay: NormalizedRelayUrl, + time: Long, + ) = addOrUpdate(relay, time) +} diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/MutableTime.kt similarity index 97% rename from ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/MutableTime.kt index 67dfd8f2f..2aedc0432 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/MutableTime.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/MutableTime.kt @@ -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.ammolite.relays.filters +package com.vitorpamplona.amethyst.commons.relays /* * Wrapper class to allow changing in EOSE without modifying the list it is included within diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index 982196e72..ba5a13bf0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.robohash.parts.mouth9Closed import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 +import org.jetbrains.compose.ui.tooling.preview.Preview val Black = SolidColor(Color.Black) val Gray = SolidColor(Color(0xFF6d6e70)) @@ -106,6 +107,7 @@ val MediumGray = SolidColor(Color(0xFFd0d2d3)) val DefaultSize = 55.dp const val VIEWPORT_SIZE = 300f +@Preview @Composable fun RobohashPreview() { val assembler = RobohashAssembler() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt index e58577de5..37e1faa37 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt @@ -21,18 +21,24 @@ package com.vitorpamplona.amethyst.commons.robohash.parts import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Accessory0SevenPreview() { Image( + modifier = Modifier.size(10000.dp), painter = rememberVectorPainter( roboBuilder { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt index b467f1c61..b650de0bc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt @@ -29,7 +29,9 @@ import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Accessory1NosePreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt index 75ae3d3e6..bc644203e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt @@ -34,7 +34,9 @@ import com.vitorpamplona.amethyst.commons.robohash.LightGray import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.MediumGray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Accessory2HornRedPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt index d52a26aaa..021040dab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Accessory3ButtonPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt index d414a5302..13546dc08 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Accessory4SatellitePreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt index ec6cad933..1a9f43667 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt @@ -29,7 +29,9 @@ import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Accessory5MustachePreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt index f11ab8e24..4fe0eb588 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body0TropperPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt index b20f22d08..d0c41903a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body1ThinPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt index 520bc1566..f0fc68638 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body2ThinnestPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt index 646f5e332..01acfb160 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body3FrontPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt index 9a5115f7f..b2a6e7340 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body4RoundPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt index 11bb4cd4d..f389f36e7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body5NeckPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt index 373a62121..c84eab7c3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt @@ -31,7 +31,9 @@ import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body6IronManPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt index f6cf8f407..5f592e53d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body7NeckThinnerPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt index ab93ec93b..7d9f9c578 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body8BigPreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt index 98810d09d..6f2c4ecd2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder +import org.jetbrains.compose.ui.tooling.preview.Preview +@Preview @Composable fun Body9HugePreview() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt new file mode 100644 index 000000000..daeabe2fe --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt @@ -0,0 +1,232 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.state + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Generic event collection state with deduplication, batching, sorting, and size limits. + * + * Provides efficient management of event/item collections with: + * - Automatic deduplication by ID + * - Batched updates (250ms default) to reduce recomposition + * - Optional sorting via comparator + * - Automatic trimming to max size + * - Thread-safe operations + * + * @param T The type of items to collect (must have a unique ID) + * @param getId Function to extract unique ID from an item + * @param sortComparator Optional comparator for sorting items (null = prepend newest first) + * @param maxSize Maximum number of items to keep (older items trimmed) + * @param batchDelayMs Delay in milliseconds before flushing batched updates (default 250ms) + * @param scope CoroutineScope for batching jobs + * + * Usage example: + * ``` + * val feedState = EventCollectionState( + * getId = { it.id }, + * sortComparator = compareByDescending { it.createdAt }, + * maxSize = 200, + * scope = viewModelScope + * ) + * + * // Add items (batched automatically) + * feedState.addItem(event) + * feedState.addItems(eventList) + * + * // Observe + * val items by feedState.items.collectAsState() + * ``` + */ +class EventCollectionState( + private val getId: (T) -> String, + private val sortComparator: Comparator? = null, + private val maxSize: Int = 200, + private val batchDelayMs: Long = 250, + private val scope: CoroutineScope, +) { + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + private val seenIds = mutableSetOf() + private val pendingItems = mutableListOf() + private val mutex = Mutex() + private var batchJob: Job? = null + + /** + * Add a single item to the collection. + * Updates are batched and applied after batchDelayMs. + * + * @param item The item to add + */ + fun addItem(item: T) { + scope.launch { + mutex.withLock { + val itemId = getId(item) + if (itemId !in seenIds) { + pendingItems.add(item) + scheduleBatchUpdate() + } + } + } + } + + /** + * Add multiple items to the collection. + * Updates are batched and applied after batchDelayMs. + * + * @param items The items to add + */ + fun addItems(items: List) { + scope.launch { + mutex.withLock { + val newItems = items.filter { getId(it) !in seenIds } + if (newItems.isNotEmpty()) { + pendingItems.addAll(newItems) + scheduleBatchUpdate() + } + } + } + } + + /** + * Remove an item by ID. + * + * @param id The ID of the item to remove + */ + fun removeItem(id: String) { + scope.launch { + mutex.withLock { + seenIds.remove(id) + _items.value = _items.value.filter { getId(it) != id } + } + } + } + + /** + * Remove multiple items by ID. + * + * @param ids The IDs of items to remove + */ + fun removeItems(ids: Set) { + scope.launch { + mutex.withLock { + seenIds.removeAll(ids) + _items.value = _items.value.filter { getId(it) !in ids } + } + } + } + + /** + * Clear all items from the collection. + */ + fun clear() { + scope.launch { + mutex.withLock { + seenIds.clear() + pendingItems.clear() + _items.value = emptyList() + batchJob?.cancel() + batchJob = null + } + } + } + + /** + * Get current item count. + */ + val size: Int + get() = _items.value.size + + /** + * Check if collection is empty. + */ + val isEmpty: Boolean + get() = _items.value.isEmpty() + + /** + * Schedules a batched update if not already scheduled. + * Cancels existing batch job and starts a new one. + */ + private fun scheduleBatchUpdate() { + batchJob?.cancel() + batchJob = + scope.launch { + delay(batchDelayMs) + applyBatchUpdate() + } + } + + /** + * Applies pending items to the collection. + * Merges with existing items, sorts if comparator provided, and trims to maxSize. + */ + private suspend fun applyBatchUpdate() { + mutex.withLock { + if (pendingItems.isEmpty()) return + + // Add pending IDs to seenIds + pendingItems.forEach { seenIds.add(getId(it)) } + + // Merge with existing items + val merged = _items.value + pendingItems + + // Sort if comparator provided, otherwise keep newest first (pending items already at end) + val sorted = + if (sortComparator != null) { + merged.sortedWith(sortComparator).distinctBy { getId(it) } + } else { + // Reverse so newest (pending) items come first + (pendingItems.reversed() + _items.value).distinctBy { getId(it) } + } + + // Trim to maxSize and update seenIds + val trimmed = + if (sorted.size > maxSize) { + val kept = sorted.take(maxSize) + val removed = sorted.drop(maxSize) + removed.forEach { seenIds.remove(getId(it)) } + kept + } else { + sorted + } + + _items.value = trimmed + pendingItems.clear() + } + } + + /** + * Force flush pending items immediately without waiting for batch delay. + */ + suspend fun flush() { + batchJob?.cancel() + applyBatchUpdate() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/FollowState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/FollowState.kt new file mode 100644 index 000000000..218e1454b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/FollowState.kt @@ -0,0 +1,163 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.state + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Represents the current follow relationship status. + * + * @property isFollowing Whether the target user is currently followed + * @property contactList The current ContactListEvent (NIP-02 kind 3) + */ +@Immutable +data class FollowStatus( + val isFollowing: Boolean, + val contactList: ContactListEvent?, +) + +/** + * Reactive state for tracking follow/unfollow status and actions. + * + * Combines current follow status with action state (loading, success, error) + * using LoadingState pattern. Delegates business logic to FollowAction. + * + * Usage: + * ``` + * val followState = FollowState(myPubKeyHex) + * + * // Update when contact list arrives from relay + * followState.updateContactList(contactListEvent, targetPubKeyHex) + * + * // Follow action + * followState.setFollowLoading() + * try { + * val updated = FollowAction.follow(targetPubKeyHex, signer, contactList) + * relayManager.broadcast(updated) + * followState.setFollowSuccess(updated, targetPubKeyHex) + * } catch (e: Exception) { + * followState.setFollowError(e.message ?: "Follow failed") + * } + * + * // Observe in UI + * when (val state = followState.state.collectAsState().value) { + * is LoadingState.Idle -> { /* Not loaded yet */ } + * is LoadingState.Loading -> CircularProgressIndicator() + * is LoadingState.Success -> { + * val status = state.data + * if (status.isFollowing) UnfollowButton() else FollowButton() + * } + * is LoadingState.Error -> ErrorMessage(state.message) + * } + * ``` + * + * @property myPubKeyHex The current user's public key hex (for context) + */ +@Stable +class FollowState( + private val myPubKeyHex: String, +) { + private val _state = MutableStateFlow>(LoadingState.Idle) + val state: StateFlow> = _state.asStateFlow() + + /** + * Updates the follow status based on a ContactListEvent. + * + * Checks if targetPubKeyHex is in the contact list's p-tags. + * + * @param event The ContactListEvent (NIP-02 kind 3) + * @param targetPubKeyHex The public key of the user to check + */ + fun updateContactList( + event: ContactListEvent, + targetPubKeyHex: String, + ) { + val isFollowing = FollowAction.isFollowing(targetPubKeyHex, event) + _state.value = LoadingState.Success(FollowStatus(isFollowing, event)) + } + + /** + * Sets the state to Loading (follow/unfollow action in progress). + * + * Call this before initiating a follow/unfollow action. + */ + fun setFollowLoading() { + _state.value = LoadingState.Loading + } + + /** + * Sets the state to Success with updated follow status. + * + * Call this after successfully broadcasting a follow/unfollow event. + * + * @param newContactList The updated ContactListEvent + * @param targetPubKeyHex The public key of the user that was followed/unfollowed + */ + fun setFollowSuccess( + newContactList: ContactListEvent, + targetPubKeyHex: String, + ) { + val isFollowing = FollowAction.isFollowing(targetPubKeyHex, newContactList) + _state.value = LoadingState.Success(FollowStatus(isFollowing, newContactList)) + } + + /** + * Sets the state to Error. + * + * Call this if follow/unfollow action fails. + * + * @param message The error message + * @param throwable Optional throwable for debugging + */ + fun setFollowError( + message: String, + throwable: Throwable? = null, + ) { + _state.value = LoadingState.Error(message, throwable) + } + + /** + * Gets the current follow status, if loaded. + * + * @return FollowStatus if state is Success, null otherwise + */ + fun currentStatusOrNull(): FollowStatus? = state.value.dataOrNull() + + /** + * Gets the current ContactListEvent, if loaded. + * + * @return ContactListEvent if state is Success, null otherwise + */ + fun currentContactListOrNull(): ContactListEvent? = currentStatusOrNull()?.contactList + + /** + * Checks if currently following, if loaded. + * + * @return true if following, false if not following or not loaded + */ + fun isFollowing(): Boolean = currentStatusOrNull()?.isFollowing ?: false +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/LoadingState.kt new file mode 100644 index 000000000..fe15f18e9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/LoadingState.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.commons.state + +/** + * Generic loading state representation for async operations. + * + * Provides type-safe state management for loading, success, error, and empty states. + * Eliminates the need for multiple boolean flags (isLoading, hasError, etc.). + * + * @param T The type of data when successfully loaded + * + * Usage example: + * ``` + * val feedState: StateFlow>> = ... + * + * when (val state = feedState.collectAsState().value) { + * is LoadingState.Idle -> { /* Not started yet */ } + * is LoadingState.Loading -> CircularProgressIndicator() + * is LoadingState.Success -> LazyColumn { items(state.data) { ... } } + * is LoadingState.Error -> ErrorMessage(state.message) + * is LoadingState.Empty -> EmptyPlaceholder() + * } + * ``` + */ +sealed class LoadingState { + /** + * Initial state - operation has not started yet. + * Useful for actions (like follow/unfollow) that haven't been triggered. + */ + object Idle : LoadingState() + + /** + * Operation is in progress. + */ + object Loading : LoadingState() + + /** + * Operation completed successfully with data. + * + * @param data The loaded data + */ + data class Success( + val data: T, + ) : LoadingState() + + /** + * Operation failed with an error. + * + * @param message Human-readable error message + * @param throwable Optional exception for debugging/logging + */ + data class Error( + val message: String, + val throwable: Throwable? = null, + ) : LoadingState() + + /** + * Operation completed successfully but returned no data. + * Useful for feeds/lists that have no items. + */ + object Empty : LoadingState() + + /** + * Returns true if this state represents a successful load (Success or Empty). + */ + val isSuccessful: Boolean + get() = this is Success || this is Empty + + /** + * Returns true if this state represents a failure. + */ + val isError: Boolean + get() = this is Error + + /** + * Returns true if this state is currently loading. + */ + val isLoading: Boolean + get() = this is Loading + + /** + * Returns the data if this is a Success state, null otherwise. + */ + fun dataOrNull(): T? = if (this is Success) data else null + + /** + * Returns the error message if this is an Error state, null otherwise. + */ + fun errorOrNull(): String? = if (this is Error) message else null +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/UserMetadataState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/UserMetadataState.kt new file mode 100644 index 000000000..c19a5aeb5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/UserMetadataState.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.commons.state + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.User +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Reactive state wrapper for User metadata. + * + * Provides a StateFlow that emits UserState when user metadata changes. + * Used by both Android ViewModels and Desktop composables for reactive UI updates. + * + * Android pattern: UserBundledRefresherFlow (typealias for backwards compatibility) + */ +@Stable +class UserMetadataState( + val user: User, +) { + val stateFlow = MutableStateFlow(UserState(user)) + + fun invalidateData() { + stateFlow.tryEmit(UserState(user)) + } + + fun hasObservers() = stateFlow.subscriptionCount.value > 0 +} + +/** + * Immutable snapshot of User state. + * + * Emitted by UserMetadataState.stateFlow to trigger recomposition. + */ +@Immutable +class UserState( + val user: User, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.kt new file mode 100644 index 000000000..8892ff2a6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.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.commons.threading + +/** + * Checks that the current code is not running on the main thread. + * Throws an exception in debug builds if called from the main thread. + * + * Platform-specific: Android uses Looper, Desktop may use different mechanism or no-op. + */ +expect fun checkNotInMainThread() + +/** + * Exception thrown when code expected to run on a background thread is executed on the main thread. + */ +class OnMainThreadException( + str: String, +) : RuntimeException(str) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt index b549466dd..2ed9eb625 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt @@ -34,6 +34,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.SharedRes +import dev.icerock.moko.resources.compose.stringResource /** * A centered loading state with a progress indicator and message. @@ -67,8 +69,10 @@ fun EmptyState( modifier: Modifier = Modifier, description: String? = null, onRefresh: (() -> Unit)? = null, - refreshLabel: String = "Refresh", + refreshLabel: String? = null, ) { + val actualRefreshLabel = refreshLabel ?: stringResource(SharedRes.strings.action_refresh) + Column( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -90,7 +94,7 @@ fun EmptyState( if (onRefresh != null) { Spacer(Modifier.height(16.dp)) OutlinedButton(onClick = onRefresh) { - Text(refreshLabel) + Text(actualRefreshLabel) } } } @@ -104,8 +108,10 @@ fun ErrorState( message: String, modifier: Modifier = Modifier, onRetry: (() -> Unit)? = null, - retryLabel: String = "Try Again", + retryLabel: String? = null, ) { + val actualRetryLabel = retryLabel ?: stringResource(SharedRes.strings.action_try_again) + Column( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -119,7 +125,7 @@ fun ErrorState( if (onRetry != null) { Spacer(Modifier.height(16.dp)) Button(onClick = onRetry) { - Text(retryLabel) + Text(actualRetryLabel) } } } @@ -131,11 +137,13 @@ fun ErrorState( @Composable fun FeedEmptyState( modifier: Modifier = Modifier, - title: String = "Feed is empty", + title: String? = null, onRefresh: (() -> Unit)? = null, ) { + val actualTitle = title ?: stringResource(SharedRes.strings.feed_empty) + EmptyState( - title = title, + title = actualTitle, modifier = modifier, onRefresh = onRefresh, ) @@ -150,8 +158,10 @@ fun FeedErrorState( modifier: Modifier = Modifier, onRetry: (() -> Unit)? = null, ) { + val formattedMessage = stringResource(SharedRes.strings.error_loading_feed).format(errorMessage) + ErrorState( - message = "Error loading feed: $errorMessage", + message = formattedMessage, modifier = modifier, onRetry = onRetry, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt new file mode 100644 index 000000000..fc9ef4ddc --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.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.commons.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash +import com.vitorpamplona.amethyst.commons.ui.theme.isLight + +/** + * Shared avatar component that displays a user's profile picture with Robohash fallback. + * + * @param userHex The user's public key hex (used for Robohash generation) + * @param pictureUrl Optional URL to the user's profile picture + * @param size Size of the avatar (both width and height) + * @param modifier Additional modifiers to apply + * @param contentDescription Accessibility description + * @param loadProfilePicture Whether to load the profile picture (false = show robohash only) + * @param loadRobohash Whether to generate robohash (false = show generic icon) + */ +@Composable +fun UserAvatar( + userHex: String, + pictureUrl: String?, + size: Dp, + modifier: Modifier = Modifier, + contentDescription: String? = null, + loadProfilePicture: Boolean = true, + loadRobohash: Boolean = true, +) { + val avatarModifier = + remember(size) { + modifier.clip(shape = CircleShape) + } + + if (pictureUrl != null && loadProfilePicture) { + // Show profile picture with robohash/icon as fallback + val fallbackPainter = + if (loadRobohash) { + rememberVectorPainter( + image = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight), + ) + } else { + rememberVectorPainter( + image = Icons.Default.Face, + ) + } + + AsyncImage( + model = pictureUrl, + contentDescription = contentDescription, + modifier = avatarModifier, + placeholder = fallbackPainter, + fallback = fallbackPainter, + error = fallbackPainter, + alignment = Alignment.Center, + contentScale = ContentScale.Crop, + alpha = DefaultAlpha, + colorFilter = null, + filterQuality = DrawScope.DefaultFilterQuality, + ) + } else if (loadRobohash) { + // Show robohash only + Image( + imageVector = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight), + contentDescription = contentDescription, + modifier = avatarModifier, + contentScale = ContentScale.Crop, + ) + } else { + // Show generic icon + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = avatarModifier, + contentScale = ContentScale.Crop, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt index 4048a5c02..3a30ba107 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt @@ -28,6 +28,8 @@ 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.commons.SharedRes +import dev.icerock.moko.resources.compose.stringResource /** * Generic placeholder screen with title and description. @@ -58,8 +60,8 @@ fun PlaceholderScreen( @Composable fun SearchPlaceholder(modifier: Modifier = Modifier) { PlaceholderScreen( - title = "Search", - description = "Search for users, notes, and hashtags.", + title = stringResource(SharedRes.strings.screen_search_title), + description = stringResource(SharedRes.strings.screen_search_description), modifier = modifier, ) } @@ -70,8 +72,8 @@ fun SearchPlaceholder(modifier: Modifier = Modifier) { @Composable fun MessagesPlaceholder(modifier: Modifier = Modifier) { PlaceholderScreen( - title = "Messages", - description = "Your encrypted direct messages will appear here.", + title = stringResource(SharedRes.strings.screen_messages_title), + description = stringResource(SharedRes.strings.screen_messages_description), modifier = modifier, ) } @@ -82,8 +84,8 @@ fun MessagesPlaceholder(modifier: Modifier = Modifier) { @Composable fun NotificationsPlaceholder(modifier: Modifier = Modifier) { PlaceholderScreen( - title = "Notifications", - description = "Mentions, replies, and reactions will appear here.", + title = stringResource(SharedRes.strings.screen_notifications_title), + description = stringResource(SharedRes.strings.screen_notifications_description), modifier = modifier, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt similarity index 56% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt index e3502dfc2..543b3861c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt @@ -18,40 +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.commons.navigation +package com.vitorpamplona.amethyst.commons.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.luminance /** - * Main application screens shared between Desktop and Android. - * Each platform implements its own navigation using these identifiers. + * Determines if the color scheme is light mode. + * Based on primary color luminance. */ -enum class AppScreen( - val label: String, - val route: String, -) { - Feed("Feed", "feed"), - Search("Search", "search"), - Messages("Messages", "messages"), - Notifications("Notifications", "notifications"), - Profile("Profile", "profile"), - Settings("Settings", "settings"), -} +val ColorScheme.isLight: Boolean + get() = primary.luminance() < 0.5f /** - * Primary navigation destinations (shown in bottom bar on mobile, sidebar on desktop). + * Color filter for onBackground color (for tinting icons/images). */ -val primaryScreens = - listOf( - AppScreen.Feed, - AppScreen.Search, - AppScreen.Messages, - AppScreen.Notifications, - AppScreen.Profile, - ) - -/** - * Secondary navigation destinations (settings, etc.) - */ -val secondaryScreens = - listOf( - AppScreen.Settings, - ) +val ColorScheme.onBackgroundColorFilter: ColorFilter + get() = ColorFilter.tint(onBackground) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.kt new file mode 100644 index 000000000..8b677f5ed --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/EmojiUtils.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.amethyst.commons.util + +import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder +import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists + +fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2 + +fun String.firstFullCharOld(): String { + return when (this.length) { + 0, + 1, + -> return this + 2, + 3, + -> return if (isUTF16Char(0)) this.take(2) else this.take(1) + else -> { + val first = isUTF16Char(0) + val second = isUTF16Char(2) + if (first && second) { + this.take(4) + } else if (first) { + this.take(2) + } else { + this.take(1) + } + } + } +} + +fun String.firstFullChar(): String { + var isInJoin = false + var hasHadSecondChance = false + var start = 0 + var previousCharLength = 0 + var next: Int + var codePoint: Int + + var i = 0 + + while (i < this.length) { + codePoint = codePointAt(i) + + // Skips if it starts with the join char 0x200D + if (codePoint == 0x200D && previousCharLength == 0) { + next = offsetByCodePoints(i, 1) + start = next + } else { + // If join, searches for the next char + if (codePoint == 0xFE0F) { + } else if (codePoint == 0x200D) { + isInJoin = true + } else { + // stops when two chars are not joined together + if (previousCharLength > 0 && !isInJoin) { + if (Character.charCount(codePoint) == 1 || hasHadSecondChance) { + break + } else { + hasHadSecondChance = true + } + } else { + hasHadSecondChance = false + } + + isInJoin = false + } + + // next char to evaluate + next = offsetByCodePoints(i, 1) + previousCharLength += (next - i) + } + + i = next + } + + // if ends in join, then seachers backwards until a char is found. + if (isInJoin) { + i = previousCharLength - 1 + while (i > 0) { + if (this[i].code == 0x200D) { + previousCharLength -= 1 + } else { + break + } + + i -= 1 + } + } + + return substring(start, start + previousCharLength) +} + +fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists): String { + if (length <= 2) { + return firstFullChar() + } + + if (this[0] == ':') { + // makes sure an emoji exists + val emojiParts = this.split(":", limit = 3) + if (emojiParts.size >= 2) { + val emojiName = emojiParts[1] + val emojiUrl = tags.lists.firstOrNull { it.size > 1 && it[1] == emojiName }?.getOrNull(2) + if (emojiUrl != null) { + return ":$emojiName:$emojiUrl" + } + } + } + + if (EmojiCoder.isCoded(this)) { + return EmojiCoder.cropToFirstMessage(this) + } + + return firstFullChar() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/IterableUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/IterableUtils.kt new file mode 100644 index 000000000..eef13c149 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/IterableUtils.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.amethyst.commons.util + +fun Iterable.replace( + old: T, + new: T, +): List = map { if (it == old) new else it } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.kt new file mode 100644 index 000000000..3691fc1f8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.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.commons.util + +/** + * Platform-agnostic number formatter. + * Uses NumberFormat on JVM platforms, custom formatting elsewhere. + */ +expect class PlatformNumberFormatter() { + fun format(value: Long): String +} diff --git a/commons/src/commonMain/moko-resources/base/strings.xml b/commons/src/commonMain/moko-resources/base/strings.xml new file mode 100644 index 000000000..7403080ba --- /dev/null +++ b/commons/src/commonMain/moko-resources/base/strings.xml @@ -0,0 +1,53 @@ + + + + Welcome to Amethyst + Sign in to your Nostr account + A Nostr client for desktop + Login with your Nostr key + Use nsec for full access or npub for read-only + Login with Key + Login + Generate New Key + Generate New + Enter your private key (nsec) or public key (npub) + nsec or npub + nsec1... or npub1... + Show key + Hide key + + + IMPORTANT: Save your keys! + Your secret key (nsec) is the ONLY way to access your account. If you lose it, your account is gone forever. Save it somewhere safe! + Public Key (shareable): + Secret Key (NEVER share this!): + I've saved my keys, continue + + + Copy + Paste + Cancel + OK + Save + Delete + Share + + + Invalid key format. Please check and try again. + Network error. Please check your connection. + An error occurred. Please try again. + + + Refresh + Try Again + Feed is empty + Error loading feed: %s + + + Search + Search for users, notes, and hashtags. + Messages + Your encrypted direct messages will appear here. + Notifications + Mentions, replies, and reactions will appear here. + diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/Base83Test.kt similarity index 100% rename from commons/src/test/java/com/vitorpamplona/amethyst/commons/Base83Test.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/Base83Test.kt diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/SRGBTest.kt similarity index 100% rename from commons/src/test/java/com/vitorpamplona/amethyst/commons/SRGBTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/SRGBTest.kt diff --git a/commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt similarity index 100% rename from commons/src/test/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoderTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuildersTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuildersTest.kt new file mode 100644 index 000000000..4afb6e0ea --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/filters/FilterBuildersTest.kt @@ -0,0 +1,510 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.filters + +import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.commons.subscriptions.buildFilter +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FilterBuildersTest { + private val testPubKey = "0000000000000000000000000000000000000000000000000000000000000001" + private val testPubKey2 = "0000000000000000000000000000000000000000000000000000000000000002" + private val testEventId = "1111111111111111111111111111111111111111111111111111111111111111" + + @Test + fun testTextNotesGlobal() { + val filter = FilterBuilders.textNotesGlobal(limit = 50) + + assertEquals(listOf(1), filter.kinds) + assertEquals(50, filter.limit) + assertNull(filter.authors) + assertNull(filter.tags) + assertNull(filter.since) + assertNull(filter.until) + } + + @Test + fun testTextNotesGlobalWithTimeRange() { + val since = 1609459200L // 2021-01-01 + val until = 1640995200L // 2022-01-01 + val filter = FilterBuilders.textNotesGlobal(limit = 100, since = since, until = until) + + assertEquals(listOf(1), filter.kinds) + assertEquals(100, filter.limit) + assertEquals(since, filter.since) + assertEquals(until, filter.until) + } + + @Test + fun testTextNotesFromAuthors() { + val authors = listOf(testPubKey, testPubKey2) + val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 25) + + assertEquals(listOf(1), filter.kinds) + assertEquals(authors, filter.authors) + assertEquals(25, filter.limit) + assertNull(filter.tags) + } + + @Test + fun testTextNotesFromAuthorsWithTimeRange() { + val authors = listOf(testPubKey) + val since = 1609459200L + val filter = FilterBuilders.textNotesFromAuthors(authors, limit = 10, since = since) + + assertEquals(listOf(1), filter.kinds) + assertEquals(authors, filter.authors) + assertEquals(10, filter.limit) + assertEquals(since, filter.since) + } + + @Test + fun testUserMetadata() { + val filter = FilterBuilders.userMetadata(testPubKey) + + assertEquals(listOf(0), filter.kinds) + assertEquals(listOf(testPubKey), filter.authors) + assertEquals(1, filter.limit) + } + + @Test + fun testContactList() { + val filter = FilterBuilders.contactList(testPubKey) + + assertEquals(listOf(3), filter.kinds) + assertEquals(listOf(testPubKey), filter.authors) + assertEquals(1, filter.limit) + } + + @Test + fun testNotificationsForUser() { + val filter = FilterBuilders.notificationsForUser(testPubKey, limit = 100) + + assertEquals(listOf(1, 7, 6, 16, 9735), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf(testPubKey), filter.tags!!["p"]) + assertEquals(100, filter.limit) + } + + @Test + fun testNotificationsForUserWithSince() { + val since = 1609459200L + val filter = FilterBuilders.notificationsForUser(testPubKey, limit = 50, since = since) + + assertEquals(listOf(1, 7, 6, 16, 9735), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf(testPubKey), filter.tags!!["p"]) + assertEquals(50, filter.limit) + assertEquals(since, filter.since) + } + + @Test + fun testByKinds() { + val kinds = listOf(1, 7, 6) + val filter = FilterBuilders.byKinds(kinds, limit = 20) + + assertEquals(kinds, filter.kinds) + assertEquals(20, filter.limit) + assertNull(filter.authors) + assertNull(filter.tags) + } + + @Test + fun testByKindsWithTimeRange() { + val kinds = listOf(30023) // Long-form content + val since = 1609459200L + val until = 1640995200L + val filter = FilterBuilders.byKinds(kinds, limit = 5, since = since, until = until) + + assertEquals(kinds, filter.kinds) + assertEquals(5, filter.limit) + assertEquals(since, filter.since) + assertEquals(until, filter.until) + } + + @Test + fun testByAuthors() { + val authors = listOf(testPubKey, testPubKey2) + val filter = FilterBuilders.byAuthors(authors, limit = 30) + + assertEquals(authors, filter.authors) + assertEquals(30, filter.limit) + assertNull(filter.kinds) + } + + @Test + fun testByAuthorsWithKinds() { + val authors = listOf(testPubKey) + val kinds = listOf(1, 30023) + val filter = FilterBuilders.byAuthors(authors, kinds = kinds, limit = 15) + + assertEquals(authors, filter.authors) + assertEquals(kinds, filter.kinds) + assertEquals(15, filter.limit) + } + + @Test + fun testByIds() { + val ids = listOf(testEventId) + val filter = FilterBuilders.byIds(ids) + + assertEquals(ids, filter.ids) + assertNull(filter.kinds) + assertNull(filter.authors) + assertNull(filter.limit) + } + + @Test + fun testByPTags() { + val pubKeys = listOf(testPubKey, testPubKey2) + val filter = FilterBuilders.byPTags(pubKeys, limit = 40) + + assertNotNull(filter.tags) + assertEquals(pubKeys, filter.tags!!["p"]) + assertEquals(40, filter.limit) + assertNull(filter.kinds) + } + + @Test + fun testByPTagsWithKinds() { + val pubKeys = listOf(testPubKey) + val kinds = listOf(7) // Reactions + val filter = FilterBuilders.byPTags(pubKeys, kinds = kinds, limit = 25) + + assertNotNull(filter.tags) + assertEquals(pubKeys, filter.tags!!["p"]) + assertEquals(kinds, filter.kinds) + assertEquals(25, filter.limit) + } + + @Test + fun testByETags() { + val eventIds = listOf(testEventId) + val filter = FilterBuilders.byETags(eventIds, limit = 10) + + assertNotNull(filter.tags) + assertEquals(eventIds, filter.tags!!["e"]) + assertEquals(10, filter.limit) + assertNull(filter.kinds) + } + + @Test + fun testByETagsWithKinds() { + val eventIds = listOf(testEventId) + val kinds = listOf(1, 7) // Text notes and reactions + val filter = FilterBuilders.byETags(eventIds, kinds = kinds, limit = 20) + + assertNotNull(filter.tags) + assertEquals(eventIds, filter.tags!!["e"]) + assertEquals(kinds, filter.kinds) + assertEquals(20, filter.limit) + } + + @Test + fun testByTags() { + val tags = mapOf("p" to listOf(testPubKey), "t" to listOf("bitcoin", "nostr")) + val filter = FilterBuilders.byTags(tags, limit = 15) + + assertEquals(tags, filter.tags) + assertEquals(15, filter.limit) + assertNull(filter.kinds) + } + + @Test + fun testByTagsWithKinds() { + val tags = mapOf("t" to listOf("bitcoin")) + val kinds = listOf(1) + val filter = FilterBuilders.byTags(tags, kinds = kinds, limit = 50) + + assertEquals(tags, filter.tags) + assertEquals(kinds, filter.kinds) + assertEquals(50, filter.limit) + } + + // DSL Builder Tests + + @Test + fun testBuildFilterWithKinds() { + val filter = + buildFilter { + kinds(1, 7) + limit(50) + } + + assertEquals(listOf(1, 7), filter.kinds) + assertEquals(50, filter.limit) + } + + @Test + fun testBuildFilterWithAuthors() { + val filter = + buildFilter { + authors(testPubKey, testPubKey2) + limit(25) + } + + assertEquals(listOf(testPubKey, testPubKey2), filter.authors) + assertEquals(25, filter.limit) + } + + @Test + fun testBuildFilterWithPTag() { + val filter = + buildFilter { + kinds(7) + pTag(testPubKey) + limit(100) + } + + assertEquals(listOf(7), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf(testPubKey), filter.tags!!["p"]) + assertEquals(100, filter.limit) + } + + @Test + fun testBuildFilterWithETag() { + val filter = + buildFilter { + kinds(1) + eTag(testEventId) + limit(10) + } + + assertEquals(listOf(1), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf(testEventId), filter.tags!!["e"]) + assertEquals(10, filter.limit) + } + + @Test + fun testBuildFilterWithCustomTag() { + val filter = + buildFilter { + kinds(1) + tag("t", listOf("bitcoin", "nostr")) + limit(20) + } + + assertEquals(listOf(1), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf("bitcoin", "nostr"), filter.tags!!["t"]) + assertEquals(20, filter.limit) + } + + @Test + fun testBuildFilterWithTimeRange() { + val since = 1609459200L + val until = 1640995200L + val filter = + buildFilter { + kinds(1) + since(since) + until(until) + limit(30) + } + + assertEquals(listOf(1), filter.kinds) + assertEquals(since, filter.since) + assertEquals(until, filter.until) + assertEquals(30, filter.limit) + } + + @Test + fun testBuildFilterComplex() { + val since = 1609459200L + val filter = + buildFilter { + kinds(1, 7, 6) + authors(testPubKey) + pTag(testPubKey2) + since(since) + limit(50) + } + + assertEquals(listOf(1, 7, 6), filter.kinds) + assertEquals(listOf(testPubKey), filter.authors) + assertNotNull(filter.tags) + assertEquals(listOf(testPubKey2), filter.tags!!["p"]) + assertEquals(since, filter.since) + assertEquals(50, filter.limit) + } + + @Test + fun testBuildFilterWithSearch() { + val filter = + buildFilter { + kinds(1) + search("bitcoin") + limit(10) + } + + assertEquals(listOf(1), filter.kinds) + assertEquals("bitcoin", filter.search) + assertEquals(10, filter.limit) + } + + @Test + fun testBuildFilterWithIds() { + val filter = + buildFilter { + ids(testEventId) + } + + assertEquals(listOf(testEventId), filter.ids) + assertNull(filter.kinds) + } + + @Test + fun testBuildFilterWithIdsList() { + val ids = listOf(testEventId, "2222222222222222222222222222222222222222222222222222222222222222") + val filter = + buildFilter { + ids(ids) + } + + assertEquals(ids, filter.ids) + } + + @Test + fun testBuildFilterWithAuthorsList() { + val authors = listOf(testPubKey, testPubKey2) + val filter = + buildFilter { + authors(authors) + limit(15) + } + + assertEquals(authors, filter.authors) + assertEquals(15, filter.limit) + } + + @Test + fun testBuildFilterWithKindsList() { + val kinds = listOf(1, 7, 6, 16, 9735) + val filter = + buildFilter { + kinds(kinds) + limit(100) + } + + assertEquals(kinds, filter.kinds) + assertEquals(100, filter.limit) + } + + // Integration Tests - Real-world scenarios + + @Test + fun testGlobalFeedScenario() { + val filter = FilterBuilders.textNotesGlobal(limit = 50) + + assertTrue(!filter.isEmpty()) + assertEquals(listOf(1), filter.kinds) + assertEquals(50, filter.limit) + } + + @Test + fun testFollowingFeedScenario() { + val followedUsers = listOf(testPubKey, testPubKey2) + val filter = FilterBuilders.textNotesFromAuthors(followedUsers, limit = 50) + + assertTrue(!filter.isEmpty()) + assertEquals(listOf(1), filter.kinds) + assertEquals(followedUsers, filter.authors) + assertEquals(50, filter.limit) + } + + @Test + fun testUserProfileScenario() { + val metadataFilter = FilterBuilders.userMetadata(testPubKey) + val postsFilter = FilterBuilders.textNotesFromAuthors(listOf(testPubKey), limit = 50) + val contactListFilter = FilterBuilders.contactList(testPubKey) + + assertTrue(!metadataFilter.isEmpty()) + assertTrue(!postsFilter.isEmpty()) + assertTrue(!contactListFilter.isEmpty()) + + assertEquals(listOf(0), metadataFilter.kinds) + assertEquals(listOf(1), postsFilter.kinds) + assertEquals(listOf(3), contactListFilter.kinds) + } + + @Test + fun testNotificationsScenario() { + val filter = FilterBuilders.notificationsForUser(testPubKey, limit = 100) + + assertTrue(!filter.isEmpty()) + assertEquals(listOf(1, 7, 6, 16, 9735), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf(testPubKey), filter.tags!!["p"]) + assertEquals(100, filter.limit) + } + + @Test + fun testThreadViewScenario() { + // Getting replies to a specific event + val filter = + buildFilter { + kinds(1) + eTag(testEventId) + limit(100) + } + + assertTrue(!filter.isEmpty()) + assertEquals(listOf(1), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf(testEventId), filter.tags!!["e"]) + } + + @Test + fun testReactionsToEventScenario() { + val filter = + buildFilter { + kinds(7) // Reactions + eTag(testEventId) + limit(50) + } + + assertTrue(!filter.isEmpty()) + assertEquals(listOf(7), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf(testEventId), filter.tags!!["e"]) + } + + @Test + fun testHashtagFeedScenario() { + val filter = + buildFilter { + kinds(1) + tag("t", listOf("bitcoin")) + limit(50) + } + + assertTrue(!filter.isEmpty()) + assertEquals(listOf(1), filter.kinds) + assertNotNull(filter.tags) + assertEquals(listOf("bitcoin"), filter.tags!!["t"]) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt index 6fb4df3e3..a2cffbd11 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.account +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -45,10 +48,80 @@ sealed class AccountState { ) : AccountState() } -class AccountManager { +@Stable +class AccountManager private constructor( + private val secureStorage: SecureKeyStorage, +) { + companion object { + /** + * Creates an AccountManager instance. + * + * @param context Platform-specific context (required on Android, ignored on Desktop) + * @return AccountManager instance + */ + fun create(context: Any? = null): AccountManager { + val storage = SecureKeyStorage.create(context) + return AccountManager(storage) + } + } + private val _accountState = MutableStateFlow(AccountState.LoggedOut) val accountState: StateFlow = _accountState.asStateFlow() + /** + * Loads the last saved account from secure storage. + * Call on app startup. + */ + suspend fun loadSavedAccount(): Result { + return try { + // For simplicity, we'll store the last logged-in npub in a simple file + // and use SecureKeyStorage to retrieve the private key + val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account")) + + val privKeyHex = + secureStorage.getPrivateKey(lastNpub) + ?: return Result.failure(Exception("Private key not found for $lastNpub")) + + val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + Result.success(state) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Saves the current account to secure storage. + */ + suspend fun saveCurrentAccount(): Result { + val current = currentAccount() ?: return Result.failure(Exception("No account logged in")) + if (current.isReadOnly || current.nsec == null) { + return Result.failure(Exception("Cannot save read-only account")) + } + + return try { + val privKeyHex = + decodePrivateKeyAsHexOrNull(current.nsec) + ?: return Result.failure(Exception("Invalid nsec format")) + + secureStorage.savePrivateKey(current.npub, privKeyHex) + saveLastNpub(current.npub) + Result.success(Unit) + } catch (e: SecureStorageException) { + Result.failure(e) + } + } + fun generateNewAccount(): AccountState.LoggedIn { val keyPair = KeyPair() val signer = NostrSignerInternal(keyPair) @@ -115,11 +188,41 @@ class AccountManager { return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format.")) } - fun logout() { + suspend fun logout(deleteKey: Boolean = false) { + val current = currentAccount() + if (deleteKey && current != null) { + try { + secureStorage.deletePrivateKey(current.npub) + clearLastNpub() + } catch (e: SecureStorageException) { + // Log error but still logout + } + } _accountState.value = AccountState.LoggedOut } fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn + + // Simple file-based storage for last npub (non-sensitive data) + private fun getLastNpub(): String? { + val file = getPrefsFile() + return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null + } + + private fun saveLastNpub(npub: String) { + val file = getPrefsFile() + file.parentFile?.mkdirs() + file.writeText(npub) + } + + private fun clearLastNpub() { + getPrefsFile().delete() + } + + private fun getPrefsFile(): java.io.File { + val homeDir = System.getProperty("user.home") + return java.io.File(homeDir, ".amethyst/last_account.txt") + } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt index 45f9571c2..3e0f7159d 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -99,6 +99,14 @@ open class RelayConnectionManager( client.send(event, relays) } + /** + * Broadcasts an event to all connected relays. + */ + fun broadcastToAll(event: Event) { + val connected = connectedRelays.value + send(event, connected) + } + private fun updateRelayStatus( url: NormalizedRelayUrl, update: (RelayStatus) -> RelayStatus, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt index d0ef9308b..5e9ddefbb 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt @@ -28,11 +28,11 @@ import java.util.regex.Pattern */ object Patterns { /** - * Email address pattern from RFC 5322. + * Email address pattern from RFC 5322... From android.util.Patterns. */ val EMAIL_ADDRESS: Pattern = Pattern.compile( - "[a-zA-Z0-9+._%-]+@[a-zA-Z0-9][a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", + "[a-zA-Z0-9+._%-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+", ) /** diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.kt new file mode 100644 index 000000000..abe3dc240 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FeedSubscription.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.commons.subscriptions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Feed mode for feed subscriptions. + */ +enum class FeedMode { + GLOBAL, + FOLLOWING, +} + +/** + * Creates a subscription config for global feed (all text notes). + */ +fun createGlobalFeedSubscription( + relays: Set, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("global-feed"), + filters = listOf(FilterBuilders.textNotesGlobal(limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) + +/** + * Creates a subscription config for following feed (text notes from followed users). + */ +fun createFollowingFeedSubscription( + relays: Set, + followedUsers: List, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (followedUsers.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("following-feed"), + filters = listOf(FilterBuilders.textNotesFromAuthors(followedUsers, limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription config for contact list (kind 3). + */ +fun createContactListSubscription( + relays: Set, + pubKeyHex: String, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("contacts-${pubKeyHex.take(8)}"), + filters = listOf(FilterBuilders.contactList(pubKeyHex)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt new file mode 100644 index 000000000..011d910fa --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/FilterBuilders.kt @@ -0,0 +1,364 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.subscriptions + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +/** + * Type-safe builders for common Nostr filter patterns. + * Provides convenience functions for creating relay subscription filters. + */ +object FilterBuilders { + /** + * Creates a filter for text notes (kind 1) from all authors. + * + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @param until Timestamp for events with publication time ≤ this value + * @return Filter for global text notes + */ + fun textNotesGlobal( + limit: Int? = null, + since: Long? = null, + until: Long? = null, + ): Filter = + Filter( + kinds = listOf(1), // TextNoteEvent.KIND + limit = limit, + since = since, + until = until, + ) + + /** + * Creates a filter for text notes (kind 1) from specific authors. + * + * @param authors List of author public keys (hex-encoded, 64 chars each) + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @param until Timestamp for events with publication time ≤ this value + * @return Filter for text notes from specified authors + */ + fun textNotesFromAuthors( + authors: List, + limit: Int? = null, + since: Long? = null, + until: Long? = null, + ): Filter = + Filter( + kinds = listOf(1), // TextNoteEvent.KIND + authors = authors, + limit = limit, + since = since, + until = until, + ) + + /** + * Creates a filter for user metadata (kind 0) from a specific author. + * + * @param pubKeyHex Author public key (hex-encoded, 64 chars) + * @return Filter for user metadata (limit=1 since only latest is needed) + */ + fun userMetadata(pubKeyHex: String): Filter = + Filter( + kinds = listOf(0), // MetadataEvent.KIND + authors = listOf(pubKeyHex), + limit = 1, + ) + + /** + * Creates a filter for contact list (kind 3) from a specific author. + * + * @param pubKeyHex Author public key (hex-encoded, 64 chars) + * @return Filter for contact list (limit=1 since only latest is needed) + */ + fun contactList(pubKeyHex: String): Filter = + Filter( + kinds = listOf(3), // ContactListEvent.KIND + authors = listOf(pubKeyHex), + limit = 1, + ) + + /** + * Creates a filter for notifications (mentions, replies, reactions, reposts, zaps) for a user. + * + * Includes: + * - kind 1 (text notes mentioning user) + * - kind 7 (reactions) + * - kind 6 (reposts) + * - kind 16 (generic reposts) + * - kind 9735 (zaps) + * + * @param pubKeyHex User public key (hex-encoded, 64 chars) to filter notifications for + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for notifications targeting the specified user + */ + fun notificationsForUser( + pubKeyHex: String, + limit: Int? = null, + since: Long? = null, + ): Filter = + Filter( + kinds = + listOf( + 1, // TextNoteEvent.KIND (mentions/replies) + 7, // ReactionEvent.KIND + 6, // RepostEvent.KIND + 16, // GenericRepostEvent.KIND + 9735, // LnZapEvent.KIND + ), + tags = mapOf("p" to listOf(pubKeyHex)), + limit = limit, + since = since, + ) + + /** + * Creates a filter for specific event kinds. + * + * @param kinds List of event kinds to filter + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @param until Timestamp for events with publication time ≤ this value + * @return Filter for specified event kinds + */ + fun byKinds( + kinds: List, + limit: Int? = null, + since: Long? = null, + until: Long? = null, + ): Filter = + Filter( + kinds = kinds, + limit = limit, + since = since, + until = until, + ) + + /** + * Creates a filter for events from specific authors. + * + * @param authors List of author public keys (hex-encoded, 64 chars each) + * @param kinds Optional list of event kinds to filter (if null, all kinds) + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @param until Timestamp for events with publication time ≤ this value + * @return Filter for events from specified authors + */ + fun byAuthors( + authors: List, + kinds: List? = null, + limit: Int? = null, + since: Long? = null, + until: Long? = null, + ): Filter = + Filter( + authors = authors, + kinds = kinds, + limit = limit, + since = since, + until = until, + ) + + /** + * Creates a filter for events with specific event IDs. + * + * @param ids List of event IDs (hex-encoded, 64 chars each) + * @return Filter for specified event IDs + */ + fun byIds(ids: List): Filter = + Filter( + ids = ids, + ) + + /** + * Creates a filter for events tagged with specific p-tags (mentioning users). + * + * @param pubKeys List of public keys (hex-encoded, 64 chars each) to filter by + * @param kinds Optional list of event kinds to filter + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for events mentioning specified users + */ + fun byPTags( + pubKeys: List, + kinds: List? = null, + limit: Int? = null, + since: Long? = null, + ): Filter = + Filter( + tags = mapOf("p" to pubKeys), + kinds = kinds, + limit = limit, + since = since, + ) + + /** + * Creates a filter for events tagged with specific e-tags (referencing events). + * + * @param eventIds List of event IDs (hex-encoded, 64 chars each) to filter by + * @param kinds Optional list of event kinds to filter + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for events referencing specified events + */ + fun byETags( + eventIds: List, + kinds: List? = null, + limit: Int? = null, + since: Long? = null, + ): Filter = + Filter( + tags = mapOf("e" to eventIds), + kinds = kinds, + limit = limit, + since = since, + ) + + /** + * Creates a filter for events with custom tag filters. + * + * @param tags Map of tag names to value lists (e.g., {"p": ["pubkey1"], "t": ["bitcoin"]}) + * @param kinds Optional list of event kinds to filter + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @param until Timestamp for events with publication time ≤ this value + * @return Filter with custom tag criteria + */ + fun byTags( + tags: Map>, + kinds: List? = null, + limit: Int? = null, + since: Long? = null, + until: Long? = null, + ): Filter = + Filter( + tags = tags, + kinds = kinds, + limit = limit, + since = since, + until = until, + ) +} + +/** + * DSL builder for creating custom filters with a fluent API. + * + * Example: + * ```kotlin + * val filter = buildFilter { + * kinds(1, 7) + * authors("pubkey1", "pubkey2") + * limit(50) + * since(System.currentTimeMillis() / 1000 - 86400) // Last 24 hours + * } + * ``` + */ +class FilterBuilder { + private var ids: List? = null + private var authors: List? = null + private var kinds: List? = null + private var tags: MutableMap>? = null + private var tagsAll: MutableMap>? = null + private var since: Long? = null + private var until: Long? = null + private var limit: Int? = null + private var search: String? = null + + fun ids(vararg ids: String) { + this.ids = ids.toList() + } + + fun ids(ids: List) { + this.ids = ids + } + + fun authors(vararg authors: String) { + this.authors = authors.toList() + } + + fun authors(authors: List) { + this.authors = authors + } + + fun kinds(vararg kinds: Int) { + this.kinds = kinds.toList() + } + + fun kinds(kinds: List) { + this.kinds = kinds + } + + fun tag( + name: String, + values: List, + ) { + if (tags == null) tags = mutableMapOf() + tags!![name] = values + } + + fun tagAll( + name: String, + values: List, + ) { + if (tagsAll == null) tagsAll = mutableMapOf() + tagsAll!![name] = values + } + + fun pTag(vararg pubKeys: String) { + tag("p", pubKeys.toList()) + } + + fun eTag(vararg eventIds: String) { + tag("e", eventIds.toList()) + } + + fun since(timestamp: Long) { + this.since = timestamp + } + + fun until(timestamp: Long) { + this.until = timestamp + } + + fun limit(limit: Int) { + this.limit = limit + } + + fun search(search: String) { + this.search = search + } + + fun build(): Filter = Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search) +} + +/** + * Creates a filter using the DSL builder. + * + * Example: + * ```kotlin + * val filter = buildFilter { + * kinds(1) + * authors("pubkey1", "pubkey2") + * limit(50) + * } + * ``` + */ +fun buildFilter(block: FilterBuilder.() -> Unit): Filter = FilterBuilder().apply(block).build() diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.kt new file mode 100644 index 000000000..2355c0fac --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/ProfileSubscription.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.commons.subscriptions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Creates a subscription config for user metadata (kind 0). + */ +fun createMetadataSubscription( + relays: Set, + pubKeyHex: String, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("meta-${pubKeyHex.take(8)}"), + filters = listOf(FilterBuilders.userMetadata(pubKeyHex)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) + +/** + * Creates a subscription config for user posts (kind 1). + */ +fun createUserPostsSubscription( + relays: Set, + pubKeyHex: String, + limit: Int = 50, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("posts-${pubKeyHex.take(8)}"), + filters = listOf(FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) + +/** + * Creates a subscription config for notifications (mentions, replies, reactions, reposts, zaps). + */ +fun createNotificationsSubscription( + relays: Set, + pubKeyHex: String, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("notif-${pubKeyHex.take(8)}"), + filters = listOf(FilterBuilders.notificationsForUser(pubKeyHex, limit = limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/SubscriptionUtils.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/SubscriptionUtils.kt new file mode 100644 index 000000000..269901810 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/subscriptions/SubscriptionUtils.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.commons.subscriptions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Represents an active relay subscription that can be unsubscribed. + */ +data class SubscriptionHandle( + val subId: String, + val unsubscribe: () -> Unit, +) + +/** + * Configuration for a relay subscription. + */ +data class SubscriptionConfig( + val subId: String, + val filters: List, + val relays: Set, + val onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + val onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +) + +/** + * Composable that remembers a subscription and automatically unsubscribes on dispose. + * + * Usage: + * ```kotlin + * rememberSubscription(relayStatuses, pubKeyHex) { + * SubscriptionConfig( + * subId = "my-sub-${System.currentTimeMillis()}", + * filters = listOf(Filter(kinds = listOf(1), limit = 50)), + * relays = relayStatuses.keys, + * onEvent = { event, _, _, _ -> events.add(event) } + * ) + * } + * ``` + */ +@Composable +fun rememberSubscription( + vararg keys: Any?, + relayManager: RelayConnectionManager, + config: () -> SubscriptionConfig?, +): SubscriptionHandle? { + val subscription = remember(*keys) { config() } + + DisposableEffect(*keys, subscription?.subId) { + subscription?.let { cfg -> + if (cfg.relays.isNotEmpty()) { + relayManager.subscribe( + subId = cfg.subId, + filters = cfg.filters, + relays = cfg.relays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + cfg.onEvent(event, isLive, relay, forFilters) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + cfg.onEose(relay, forFilters) + } + }, + ) + } + } + + onDispose { + subscription?.let { relayManager.unsubscribe(it.subId) } + } + } + + return subscription?.let { SubscriptionHandle(it.subId, { relayManager.unsubscribe(it.subId) }) } +} + +/** + * Generates a unique subscription ID with timestamp. + */ +fun generateSubId(prefix: String): String = "$prefix-${System.currentTimeMillis()}" diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt index e27629c9f..21e0c4b94 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt @@ -41,6 +41,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.SharedRes +import dev.icerock.moko.resources.compose.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview /** * Text field for entering Nostr keys (nsec or npub) with visibility toggle. @@ -50,8 +53,8 @@ fun KeyInputField( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, - label: String = "nsec or npub", - placeholder: String = "nsec1... or npub1...", + label: String = stringResource(SharedRes.strings.login_key_label), + placeholder: String = stringResource(SharedRes.strings.login_key_placeholder), errorMessage: String? = null, ) { var showKey by remember { mutableStateOf(false) } @@ -73,7 +76,7 @@ fun KeyInputField( IconButton(onClick = { showKey = !showKey }) { Icon( if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showKey) "Hide key" else "Show key", + contentDescription = if (showKey) stringResource(SharedRes.strings.login_hide_key) else stringResource(SharedRes.strings.login_show_key), ) } }, @@ -108,3 +111,20 @@ fun SelectableKeyText( ) } } + +@Preview +@Composable +fun KeyInputFieldPreview() { + KeyInputField( + value = "nsec1example1234567890", + onValueChange = {}, + ) +} + +@Preview +@Composable +fun SelectableKeyTextPreview() { + SelectableKeyText( + key = "npub1example1234567890abcdefghijklmnopqrstuvwxyz1234567890", + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt index a80912b91..f97e9179b 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt @@ -43,6 +43,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.SharedRes +import dev.icerock.moko.resources.compose.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview /** * Login card with Nostr key input field and action buttons. @@ -60,8 +63,8 @@ fun LoginCard( onGenerateNew: () -> Unit, modifier: Modifier = Modifier, cardWidth: Dp = 400.dp, - title: String = "Login with your Nostr key", - subtitle: String = "Use nsec for full access or npub for read-only", + title: String = stringResource(SharedRes.strings.login_card_title), + subtitle: String = stringResource(SharedRes.strings.login_card_subtitle), ) { var keyInput by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } @@ -118,16 +121,25 @@ fun LoginCard( modifier = Modifier.weight(1f), enabled = keyInput.isNotBlank(), ) { - Text("Login") + Text(stringResource(SharedRes.strings.login_button)) } OutlinedButton( onClick = onGenerateNew, modifier = Modifier.weight(1f), ) { - Text("Generate New") + Text(stringResource(SharedRes.strings.login_generate_button)) } } } } } + +@Preview +@Composable +fun LoginCardPreview() { + LoginCard( + onLogin = { Result.success(Unit) }, + onGenerateNew = {}, + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt index 4fa976aa9..9952ee4fb 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt @@ -36,6 +36,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.SharedRes +import dev.icerock.moko.resources.compose.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview /** * Warning card displayed after generating a new Nostr key pair. @@ -66,7 +69,7 @@ fun NewKeyWarningCard( modifier = Modifier.padding(24.dp), ) { Text( - "IMPORTANT: Save your keys!", + stringResource(SharedRes.strings.new_key_warning_title), style = MaterialTheme.typography.titleMedium, color = Color.Red, ) @@ -74,8 +77,7 @@ fun NewKeyWarningCard( Spacer(Modifier.height(16.dp)) Text( - "Your secret key (nsec) is the ONLY way to access your account. " + - "If you lose it, your account is gone forever. Save it somewhere safe!", + stringResource(SharedRes.strings.new_key_warning_message), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) @@ -83,7 +85,7 @@ fun NewKeyWarningCard( Spacer(Modifier.height(16.dp)) Text( - "Public Key (shareable):", + stringResource(SharedRes.strings.new_key_public_label), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -93,7 +95,7 @@ fun NewKeyWarningCard( nsec?.let { secretKey -> Text( - "Secret Key (NEVER share this!):", + stringResource(SharedRes.strings.new_key_secret_label), style = MaterialTheme.typography.labelMedium, color = Color.Red, ) @@ -106,8 +108,18 @@ fun NewKeyWarningCard( onClick = onContinue, modifier = Modifier.fillMaxWidth(), ) { - Text("I've saved my keys, continue") + Text(stringResource(SharedRes.strings.new_key_continue_button)) } } } } + +@Preview +@Composable +fun NewKeyWarningCardPreview() { + NewKeyWarningCard( + npub = "npub1example1234567890abcdefghijklmnopqrstuvwxyz", + nsec = "nsec1example1234567890abcdefghijklmnopqrstuvwxyz", + onContinue = {}, + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt index a82e4faf8..67a70a500 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.ui.note +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -27,6 +28,7 @@ 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.width import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -43,6 +45,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.util.toTimeAgo /** @@ -50,7 +53,9 @@ import com.vitorpamplona.amethyst.commons.util.toTimeAgo */ data class NoteDisplayData( val id: String, + val pubKeyHex: String, val pubKeyDisplay: String, + val profilePictureUrl: String? = null, val content: String, val createdAt: Long, ) @@ -64,6 +69,7 @@ fun NoteCard( note: NoteDisplayData, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + onAuthorClick: ((String) -> Unit)? = null, ) { val richTextParser = remember { RichTextParser() } val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) } @@ -82,13 +88,32 @@ fun NoteCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - // Author (truncated) - Text( - text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - ) + // Author with avatar + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + if (onAuthorClick != null) { + Modifier.clickable { onAuthorClick(note.pubKeyHex) } + } else { + Modifier + }, + ) { + UserAvatar( + userHex = note.pubKeyHex, + pictureUrl = note.profilePictureUrl, + size = 32.dp, + contentDescription = "Profile picture of ${note.pubKeyDisplay}", + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + } // Timestamp Text( diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt index 1f0de406e..0c392b8f8 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt @@ -38,6 +38,7 @@ fun Event.toNoteDisplayData(): NoteDisplayData { return NoteDisplayData( id = id, + pubKeyHex = pubKey, pubKeyDisplay = npub, content = content, createdAt = createdAt, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index 88ef514b9..43221805b 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -81,6 +81,50 @@ fun timeAgo( } } +fun timeDiffAgoLong(timeDifference: Int): String = + when { + timeDifference > TimeUtils.ONE_YEAR -> { + (timeDifference / TimeUtils.ONE_YEAR).toString() + " years" + } + timeDifference > TimeUtils.ONE_MONTH -> { + (timeDifference / TimeUtils.ONE_MONTH).toString() + " months" + } + timeDifference > TimeUtils.ONE_DAY -> { + (timeDifference / TimeUtils.ONE_DAY).toString() + " days" + } + timeDifference > TimeUtils.ONE_HOUR -> { + (timeDifference / TimeUtils.ONE_HOUR).toString() + " hours" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + (timeDifference / TimeUtils.ONE_MINUTE).toString() + " minutes" + } + else -> { + "now" + } + } + +fun timeDiffAgoShortish(timeDifference: Int): String = + when { + timeDifference > TimeUtils.ONE_YEAR -> { + (timeDifference / TimeUtils.ONE_YEAR).toString() + " yrs" + } + timeDifference > TimeUtils.ONE_MONTH -> { + (timeDifference / TimeUtils.ONE_MONTH).toString() + " mos" + } + timeDifference > TimeUtils.ONE_DAY -> { + (timeDifference / TimeUtils.ONE_DAY).toString() + " days" + } + timeDifference > TimeUtils.ONE_HOUR -> { + (timeDifference / TimeUtils.ONE_HOUR).toString() + " hrs" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + (timeDifference / TimeUtils.ONE_MINUTE).toString() + " mins" + } + else -> { + "now" + } + } + /** * Formats a Unix timestamp as a date string. * For recent dates (< 1 day), returns the provided today string. diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt new file mode 100644 index 000000000..9a74a4adf --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -0,0 +1,400 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.keystorage + +import com.github.javakeyring.BackendNotSupportedException +import com.github.javakeyring.Keyring +import com.github.javakeyring.PasswordAccessException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.io.RandomAccessFile +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermission +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +/** + * Desktop implementation of SecureKeyStorage using OS-native credential managers + * (macOS Keychain, Windows Credential Manager, Linux Secret Service/KWallet). + * + * Falls back to encrypted file storage with user-provided password if OS keyring + * is unavailable. + * + * ## Fallback Storage Security + * + * When OS keyring is unavailable, the implementation uses: + * - **Encryption:** AES-256-GCM with PBKDF2 (100k iterations) + * - **File Permissions:** Owner-only read/write (600 for files, 700 for directories) on Unix systems + * - **Atomic Writes:** Temp file + atomic move to prevent corruption + * - **File Locking:** Prevents concurrent access race conditions + * + * **Password Memory Limitation:** The fallback password is stored as a String and cannot be + * securely zeroed from memory. It remains cached for the application lifetime to avoid repeated + * password prompts. This is acceptable for desktop applications where the user's session is + * already trusted, but may not be suitable for shared/multi-user systems. + */ +actual class SecureKeyStorage private actual constructor() { + actual companion object { + /** + * Creates a SecureKeyStorage instance for Desktop. + * + * @param context Ignored on Desktop (no context needed) + * @return SecureKeyStorage instance + */ + actual fun create(context: Any?): SecureKeyStorage = SecureKeyStorage() + + private const val SERVICE_NAME = "amethyst-desktop" + private const val FALLBACK_DIR = ".amethyst" + private const val FALLBACK_FILE = "keys.enc" + + // Encryption constants for fallback + private const val ALGORITHM = "AES" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val KEY_LENGTH = 256 + private const val ITERATION_COUNT = 100000 + private const val IV_LENGTH = 12 // GCM standard + } + + private var keyringAvailable: Boolean = true + private var fallbackPassword: String? = null + private val fallbackMutex = Mutex() // Protects concurrent access to fallback file + + actual suspend fun savePrivateKey( + npub: String, + privKeyHex: String, + ) { + withContext(Dispatchers.IO) { + try { + if (keyringAvailable) { + saveToKeyring(npub, privKeyHex) + } else { + saveToFallback(npub, privKeyHex) + } + } catch (e: BackendNotSupportedException) { + keyringAvailable = false + println("OS keyring not available, using fallback encrypted storage") + saveToFallback(npub, privKeyHex) + } catch (e: Exception) { + throw SecureStorageException("Failed to save private key", e) + } + } + } + + actual suspend fun getPrivateKey(npub: String): String? = + withContext(Dispatchers.IO) { + try { + if (keyringAvailable) { + getFromKeyring(npub) + } else { + getFromFallback(npub) + } + } catch (e: BackendNotSupportedException) { + keyringAvailable = false + println("OS keyring not available, using fallback encrypted storage") + getFromFallback(npub) + } catch (e: PasswordAccessException) { + null // Key doesn't exist + } catch (e: Exception) { + throw SecureStorageException("Failed to retrieve private key", e) + } + } + + actual suspend fun deletePrivateKey(npub: String): Boolean = + withContext(Dispatchers.IO) { + try { + if (keyringAvailable) { + deleteFromKeyring(npub) + } else { + deleteFromFallback(npub) + } + } catch (e: BackendNotSupportedException) { + keyringAvailable = false + deleteFromFallback(npub) + } catch (e: Exception) { + throw SecureStorageException("Failed to delete private key", e) + } + } + + actual suspend fun hasPrivateKey(npub: String): Boolean = getPrivateKey(npub) != null + + // Keyring-based storage + private fun saveToKeyring( + npub: String, + privKeyHex: String, + ) { + val keyring = Keyring.create() + keyring.setPassword(SERVICE_NAME, npub, privKeyHex) + } + + private fun getFromKeyring(npub: String): String? = + try { + val keyring = Keyring.create() + keyring.getPassword(SERVICE_NAME, npub) + } catch (e: PasswordAccessException) { + null + } + + private fun deleteFromKeyring(npub: String): Boolean = + try { + val keyring = Keyring.create() + keyring.deletePassword(SERVICE_NAME, npub) + true + } catch (e: PasswordAccessException) { + false + } + + // Fallback encrypted file storage + private suspend fun saveToFallback( + npub: String, + privKeyHex: String, + ) { + fallbackMutex.withLock { + val password = getFallbackPassword() + val encrypted = encryptData(privKeyHex, password) + + val fallbackFile = getFallbackFile() + + // Create directory with restrictive permissions + fallbackFile.parentFile?.let { dir -> + if (!dir.exists()) { + dir.mkdirs() + setRestrictivePermissions(dir) + } + } + + withFileLock(fallbackFile) { + val data = loadFallbackDataUnsafe().toMutableMap() + data[npub] = encrypted + atomicWriteFallbackData(fallbackFile, data) + } + } + } + + private suspend fun getFromFallback(npub: String): String? { + val password = fallbackPassword ?: return null // No password set yet + + return fallbackMutex.withLock { + val fallbackFile = getFallbackFile() + if (!fallbackFile.exists()) return@withLock null + + withFileLock(fallbackFile) { + val data = loadFallbackDataUnsafe() + val encrypted = data[npub] ?: return@withFileLock null + + try { + decryptData(encrypted, password) + } catch (e: Exception) { + null + } + } + } + } + + private suspend fun deleteFromFallback(npub: String): Boolean { + return fallbackMutex.withLock { + val fallbackFile = getFallbackFile() + if (!fallbackFile.exists()) return@withLock false + + withFileLock(fallbackFile) { + val data = loadFallbackDataUnsafe().toMutableMap() + val existed = data.remove(npub) != null + + if (existed) { + if (data.isEmpty()) { + fallbackFile.delete() + } else { + atomicWriteFallbackData(fallbackFile, data) + } + } + + existed + } + } + } + + /** + * Loads fallback data without locking. Caller must hold mutex and file lock. + */ + private fun loadFallbackDataUnsafe(): Map { + val fallbackFile = getFallbackFile() + if (!fallbackFile.exists()) return emptyMap() + + return fallbackFile + .readLines() + .mapNotNull { line -> + val parts = line.split(":", limit = 2) + if (parts.size == 2) parts[0] to parts[1] else null + }.toMap() + } + + /** + * Atomically writes fallback data using temp file + rename. + */ + private fun atomicWriteFallbackData( + fallbackFile: File, + data: Map, + ) { + val tempFile = File(fallbackFile.parentFile, "${fallbackFile.name}.tmp") + try { + // Write to temp file + tempFile.writeText(data.entries.joinToString("\n") { "${it.key}:${it.value}" }) + setRestrictivePermissions(tempFile) + + // Atomic rename + Files.move( + tempFile.toPath(), + fallbackFile.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } finally { + // Clean up temp file if it still exists + if (tempFile.exists()) { + tempFile.delete() + } + } + } + + /** + * Executes block with file lock held. + */ + private fun withFileLock( + file: File, + block: () -> T, + ): T { + // Ensure lock file exists + val lockFile = File(file.parentFile, "${file.name}.lock") + lockFile.parentFile?.mkdirs() + if (!lockFile.exists()) { + lockFile.createNewFile() + setRestrictivePermissions(lockFile) + } + + return RandomAccessFile(lockFile, "rw").use { raf -> + raf.channel.lock().use { lock -> + block() + } + } + } + + private fun getFallbackFile(): File { + val homeDir = System.getProperty("user.home") + return File(homeDir, "$FALLBACK_DIR/$FALLBACK_FILE") + } + + private fun getFallbackPassword(): String { + if (fallbackPassword == null) { + println("OS keyring not available. Fallback encrypted storage requires a password.") + val console = System.console() + fallbackPassword = + if (console != null) { + // Use Console.readPassword() for masked input + val password = console.readPassword("Enter master password: ") + password?.let { + val str = String(it) + it.fill('\u0000') // Clear the char array from memory + str + } ?: throw SecureStorageException("Password required for fallback storage") + } else { + // Fallback for non-interactive environments (testing, etc.) + print("Enter master password: ") + readLine() ?: throw SecureStorageException("Password required for fallback storage") + } + } + return fallbackPassword!! + } + + private fun setRestrictivePermissions(file: File) { + try { + val path = file.toPath() + // Set owner-only read/write permissions (600 for files, 700 for directories) + val permissions = + if (file.isDirectory) { + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ) + } else { + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ) + } + Files.setPosixFilePermissions(path, permissions) + } catch (e: UnsupportedOperationException) { + // Windows doesn't support POSIX permissions - file system security handles this + // No action needed + } catch (e: Exception) { + // Log but don't fail - permissions are a security enhancement, not critical + System.err.println("Warning: Could not set restrictive file permissions: ${e.message}") + } + } + + private fun encryptData( + plaintext: String, + password: String, + ): String { + val salt = ByteArray(16).apply { SecureRandom().nextBytes(this) } + val iv = ByteArray(IV_LENGTH).apply { SecureRandom().nextBytes(this) } + + val keySpec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH) + val secretKey = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(keySpec) + val key = SecretKeySpec(secretKey.encoded, ALGORITHM) + + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv)) + val encrypted = cipher.doFinal(plaintext.toByteArray()) + + val combined = salt + iv + encrypted + return Base64.getEncoder().encodeToString(combined) + } + + private fun decryptData( + ciphertext: String, + password: String, + ): String { + val combined = Base64.getDecoder().decode(ciphertext) + + val salt = combined.copyOfRange(0, 16) + val iv = combined.copyOfRange(16, 16 + IV_LENGTH) + val encrypted = combined.copyOfRange(16 + IV_LENGTH, combined.size) + + val keySpec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH) + val secretKey = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(keySpec) + val key = SecretKeySpec(secretKey.encoded, ALGORITHM) + + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) + val decrypted = cipher.doFinal(encrypted) + + return String(decrypted) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.jvm.kt new file mode 100644 index 000000000..8140c47be --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/threading/Threading.jvm.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.commons.threading + +/** + * Desktop JVM implementation of checkNotInMainThread. + * Currently a no-op as Desktop Compose doesn't have the same main thread restrictions as Android. + * Could be enhanced to check Swing EDT if needed. + */ +actual fun checkNotInMainThread() { + // No-op for Desktop - different threading model + // Could check for Swing EDT with: javax.swing.SwingUtilities.isEventDispatchThread() + // but Compose Desktop doesn't have the same restrictions as Android +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.jvm.kt new file mode 100644 index 000000000..e97a28225 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/PlatformNumberFormatter.jvm.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.commons.util + +import java.text.NumberFormat + +actual class PlatformNumberFormatter { + private val formatter = NumberFormat.getInstance() + + actual fun format(value: Long): String = formatter.format(value) +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 5bd8c6c60..b82eb646b 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -11,6 +11,10 @@ sourceSets { kotlin.srcDir("src/jvmMain/kotlin") resources.srcDir("src/jvmMain/resources") } + test { + kotlin.srcDir("src/jvmTest/kotlin") + resources.srcDir("src/jvmTest/resources") + } } kotlin { @@ -40,6 +44,11 @@ dependencies { // Collections implementation(libs.kotlinx.collections.immutable) + + // Testing + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp) } compose.desktop { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfig.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfig.kt new file mode 100644 index 000000000..85a88fde5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfig.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.desktop + +/** + * Debug configuration for the desktop application. + * + * To enable debug mode, set the environment variable: + * AMETHYST_DEBUG=true + * + * Or run with: + * ./gradlew :desktopApp:run -PamethystDebug=true + */ +object DebugConfig { + /** + * Enables developer settings and debug features. + * + * When true, shows: + * - Raw nsec/npub in settings + * - Additional logging + * - Debug UI elements + */ + val isDebugMode: Boolean by lazy { + // Check environment variable + val envDebug = System.getenv("AMETHYST_DEBUG")?.toBoolean() ?: false + + // Check system property (for gradle -PamethystDebug=true) + val propDebug = System.getProperty("amethyst.debug")?.toBoolean() ?: false + + envDebug || propDebug + } + + /** + * Log debug information. + */ + fun log(message: String) { + if (isDebugMode) { + println("[DEBUG] $message") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 7e91eb32d..eaf54d7d5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -60,6 +60,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 @@ -74,15 +75,43 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import com.vitorpamplona.amethyst.commons.account.AccountManager import com.vitorpamplona.amethyst.commons.account.AccountState -import com.vitorpamplona.amethyst.commons.navigation.AppScreen import com.vitorpamplona.amethyst.commons.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder -import com.vitorpamplona.amethyst.commons.ui.screens.NotificationsPlaceholder import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen +import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen +import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +/** + * Desktop navigation state - extends AppScreen with dynamic destinations. + */ +sealed class DesktopScreen { + object Feed : DesktopScreen() + + object Search : DesktopScreen() + + object Messages : DesktopScreen() + + object Notifications : DesktopScreen() + + object MyProfile : DesktopScreen() + + data class UserProfile( + val pubKeyHex: String, + ) : DesktopScreen() + + object Settings : DesktopScreen() +} fun main() = application { @@ -92,6 +121,7 @@ fun main() = height = 800.dp, position = WindowPosition.Aligned(Alignment.Center), ) + var showComposeDialog by remember { mutableStateOf(false) } Window( onCloseRequest = ::exitApplication, @@ -102,25 +132,58 @@ fun main() = Menu("File") { Item( "New Note", - shortcut = KeyShortcut(Key.N, ctrl = true), - onClick = { /* TODO: Open new note dialog */ }, + shortcut = + if (isMacOS) { + KeyShortcut(Key.N, meta = true) + } else { + KeyShortcut(Key.N, ctrl = true) + }, + onClick = { showComposeDialog = true }, ) Separator() Item( "Settings", - shortcut = KeyShortcut(Key.Comma, ctrl = true), + shortcut = + if (isMacOS) { + KeyShortcut(Key.Comma, meta = true) + } else { + KeyShortcut(Key.Comma, ctrl = true) + }, onClick = { /* TODO: Open settings */ }, ) Separator() Item( "Quit", - shortcut = KeyShortcut(Key.Q, ctrl = true), + shortcut = + if (isMacOS) { + KeyShortcut(Key.Q, meta = true) + } else { + KeyShortcut(Key.Q, ctrl = true) + }, onClick = ::exitApplication, ) } Menu("Edit") { - Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true), onClick = { }) - Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true), onClick = { }) + Item( + "Copy", + shortcut = + if (isMacOS) { + KeyShortcut(Key.C, meta = true) + } else { + KeyShortcut(Key.C, ctrl = true) + }, + onClick = { }, + ) + Item( + "Paste", + shortcut = + if (isMacOS) { + KeyShortcut(Key.V, meta = true) + } else { + KeyShortcut(Key.V, ctrl = true) + }, + onClick = { }, + ) } Menu("View") { Item("Feed", onClick = { }) @@ -133,18 +196,33 @@ fun main() = } } - App() + App( + showComposeDialog = showComposeDialog, + onShowComposeDialog = { showComposeDialog = true }, + onDismissComposeDialog = { showComposeDialog = false }, + ) } } @Composable -fun App() { - var currentScreen by remember { mutableStateOf(AppScreen.Feed) } +fun App( + showComposeDialog: Boolean, + onShowComposeDialog: () -> Unit, + onDismissComposeDialog: () -> Unit, +) { + var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } val relayManager = remember { DesktopRelayConnectionManager() } - val accountManager = remember { AccountManager() } + val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() + val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + // Try to load saved account on startup DisposableEffect(Unit) { + scope.launch(Dispatchers.IO) { + // Load account on IO dispatcher to avoid blocking UI with password prompt (readLine) + accountManager.loadSavedAccount() + } + relayManager.addDefaultRelays() relayManager.connect() onDispose { @@ -163,17 +241,29 @@ fun App() { is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { currentScreen = AppScreen.Feed }, + onLoginSuccess = { currentScreen = DesktopScreen.Feed }, ) } is AccountState.LoggedIn -> { + val account = accountState as AccountState.LoggedIn + MainContent( currentScreen = currentScreen, onScreenChange = { currentScreen = it }, relayManager = relayManager, accountManager = accountManager, - account = accountState as AccountState.LoggedIn, + account = account, + onShowComposeDialog = onShowComposeDialog, ) + + // Compose dialog + if (showComposeDialog) { + ComposeNoteDialog( + onDismiss = onDismissComposeDialog, + relayManager = relayManager, + account = account, + ) + } } } } @@ -182,11 +272,12 @@ fun App() { @Composable fun MainContent( - currentScreen: AppScreen, - onScreenChange: (AppScreen) -> Unit, + currentScreen: DesktopScreen, + onScreenChange: (DesktopScreen) -> Unit, relayManager: DesktopRelayConnectionManager, accountManager: AccountManager, account: AccountState.LoggedIn, + onShowComposeDialog: () -> Unit, ) { Row(Modifier.fillMaxSize()) { // Sidebar Navigation @@ -199,36 +290,36 @@ fun MainContent( NavigationRailItem( icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, label = { Text("Feed") }, - selected = currentScreen == AppScreen.Feed, - onClick = { onScreenChange(AppScreen.Feed) }, + selected = currentScreen == DesktopScreen.Feed, + onClick = { onScreenChange(DesktopScreen.Feed) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, label = { Text("Search") }, - selected = currentScreen == AppScreen.Search, - onClick = { onScreenChange(AppScreen.Search) }, + selected = currentScreen == DesktopScreen.Search, + onClick = { onScreenChange(DesktopScreen.Search) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, label = { Text("DMs") }, - selected = currentScreen == AppScreen.Messages, - onClick = { onScreenChange(AppScreen.Messages) }, + selected = currentScreen == DesktopScreen.Messages, + onClick = { onScreenChange(DesktopScreen.Messages) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, label = { Text("Alerts") }, - selected = currentScreen == AppScreen.Notifications, - onClick = { onScreenChange(AppScreen.Notifications) }, + selected = currentScreen == DesktopScreen.Notifications, + onClick = { onScreenChange(DesktopScreen.Notifications) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, label = { Text("Profile") }, - selected = currentScreen == AppScreen.Profile, - onClick = { onScreenChange(AppScreen.Profile) }, + selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile, + onClick = { onScreenChange(DesktopScreen.MyProfile) }, ) Spacer(Modifier.weight(1f)) @@ -238,8 +329,8 @@ fun MainContent( NavigationRailItem( icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, label = { Text("Settings") }, - selected = currentScreen == AppScreen.Settings, - onClick = { onScreenChange(AppScreen.Settings) }, + selected = currentScreen == DesktopScreen.Settings, + onClick = { onScreenChange(DesktopScreen.Settings) }, ) Spacer(Modifier.height(16.dp)) @@ -252,12 +343,41 @@ fun MainContent( modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), ) { when (currentScreen) { - AppScreen.Feed -> FeedScreen(relayManager) - AppScreen.Search -> SearchPlaceholder() - AppScreen.Messages -> MessagesPlaceholder() - AppScreen.Notifications -> NotificationsPlaceholder() - AppScreen.Profile -> ProfileScreen(account, accountManager) - AppScreen.Settings -> RelaySettingsScreen(relayManager) + DesktopScreen.Feed -> + FeedScreen( + relayManager = relayManager, + account = account, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + ) + DesktopScreen.Search -> SearchPlaceholder() + DesktopScreen.Messages -> MessagesPlaceholder() + DesktopScreen.Notifications -> NotificationsScreen(relayManager, account) + DesktopScreen.MyProfile -> + UserProfileScreen( + pubKeyHex = account.pubKeyHex, + relayManager = relayManager, + account = account, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + ) + is DesktopScreen.UserProfile -> + UserProfileScreen( + pubKeyHex = currentScreen.pubKeyHex, + relayManager = relayManager, + account = account, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + ) + DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account) } } } @@ -268,6 +388,8 @@ fun ProfileScreen( account: AccountState.LoggedIn, accountManager: AccountManager, ) { + val scope = rememberCoroutineScope() + Column { Text( "Profile", @@ -285,7 +407,7 @@ fun ProfileScreen( Spacer(Modifier.height(24.dp)) OutlinedButton( - onClick = { accountManager.logout() }, + onClick = { scope.launch { accountManager.logout() } }, colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors( contentColor = Color.Red, @@ -297,17 +419,37 @@ fun ProfileScreen( } @Composable -fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { +fun RelaySettingsScreen( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, +) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() var newRelayUrl by remember { mutableStateOf("") } Column(modifier = Modifier.fillMaxSize()) { Text( - "Relay Settings", + "Settings", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, ) + + Spacer(Modifier.height(24.dp)) + + // Developer Settings Section (only in debug mode) + if (DebugConfig.isDebugMode) { + com.vitorpamplona.amethyst.desktop.ui + .DevSettingsSection(account = account) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + } + + Text( + "Relay Settings", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) Spacer(Modifier.height(8.dp)) Row( @@ -362,7 +504,7 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f), ) { - items(relayStatuses.values.toList()) { status -> + items(relayStatuses.values.toList(), key = { it.url.url }) { status -> RelayStatusCard( status = status, onRemove = { relayManager.removeRelay(status.url) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt new file mode 100644 index 000000000..aca7e6664 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.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.amethyst.desktop.ui + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +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.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.model.nip10TextNotes.PublishAction +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ComposeNoteDialog( + onDismiss: () -> Unit, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, + replyTo: com.vitorpamplona.quartz.nip01Core.core.Event? = null, +) { + var content by remember { mutableStateOf("") } + var isPosting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) { + Card( + modifier = Modifier.width(600.dp).padding(16.dp), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + if (replyTo != null) "Reply" else "New Note", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + replyTo?.let { reply -> + Spacer(Modifier.height(8.dp)) + Text( + "Replying to: ${reply.content.take(50)}${if (reply.content.length > 50) "..." else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = content, + onValueChange = { + content = it + errorMessage = null + }, + modifier = Modifier.fillMaxWidth().height(200.dp), + label = { Text("What's on your mind?") }, + placeholder = { Text("Write your note...") }, + enabled = !isPosting, + maxLines = 10, + ) + + Spacer(Modifier.height(8.dp)) + + // Character count + Text( + "${content.length} characters", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + errorMessage?.let { error -> + Spacer(Modifier.height(8.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = onDismiss, + enabled = !isPosting, + ) { + Text("Cancel") + } + + Spacer(Modifier.width(8.dp)) + + Button( + onClick = { + if (content.isBlank()) { + errorMessage = "Note cannot be empty" + return@Button + } + + scope.launch { + isPosting = true + errorMessage = null + + try { + publishNote( + content = content, + account = account, + relayManager = relayManager, + replyTo = replyTo, + ) + onDismiss() + } catch (e: Exception) { + errorMessage = "Failed to publish: ${e.message}" + } finally { + isPosting = false + } + } + }, + enabled = !isPosting && content.isNotBlank(), + ) { + Text(if (isPosting) "Publishing..." else "Publish") + } + } + } + } + } +} + +/** + * Publishes a text note to relays. + * Uses the Account's key to sign the event. + */ +private suspend fun publishNote( + content: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?, +) { + withContext(Dispatchers.IO) { + // Check read-only mode + if (account.isReadOnly) { + throw IllegalStateException("Cannot post in read-only mode") + } + + // Use shared PublishAction from commons + val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo) + + // Broadcast to all configured relays + relayManager.broadcastToAll(signedEvent) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt new file mode 100644 index 000000000..6c25bd22d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt @@ -0,0 +1,261 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +/** + * Developer settings section - shows sensitive keys for debugging. + * Only enable in debug builds or with explicit debug flag. + */ +@Composable +fun DevSettingsSection( + account: AccountState.LoggedIn, + modifier: Modifier = Modifier, +) { + var showKeys by remember { mutableStateOf(false) } + + Column( + modifier = + modifier + .fillMaxWidth() + .border( + width = 2.dp, + color = Color(0xFFFF9800), // Orange warning color + shape = RoundedCornerShape(8.dp), + ).background( + color = Color(0xFF332200), // Dark orange tint + shape = RoundedCornerShape(8.dp), + ).padding(16.dp), + ) { + // Warning header + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.Warning, + contentDescription = "Warning", + tint = Color(0xFFFF9800), + ) + Text( + "Developer Settings", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF9800), + ) + } + + Spacer(Modifier.height(8.dp)) + + Text( + "Debug mode only - Handle keys with care", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + HorizontalDivider(color = Color(0xFFFF9800).copy(alpha = 0.3f)) + + Spacer(Modifier.height(16.dp)) + + // Toggle button + OutlinedButton( + onClick = { showKeys = !showKeys }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = Color(0xFFFF9800), + ), + ) { + Text(if (showKeys) "Hide Keys" else "Show Keys") + } + + if (showKeys) { + Spacer(Modifier.height(16.dp)) + + // npub + KeyRow( + label = "Public Key (npub)", + value = account.npub, + isSensitive = false, + ) + + Spacer(Modifier.height(12.dp)) + + // nsec (only if available) + account.nsec?.let { nsec -> + KeyRow( + label = "Private Key (nsec)", + value = nsec, + isSensitive = true, + ) + } ?: run { + Text( + "Read-only mode - No private key available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, + ) + } + + Spacer(Modifier.height(12.dp)) + + // Hex key + KeyRow( + label = "Public Key (hex)", + value = account.pubKeyHex, + isSensitive = false, + ) + } + } +} + +@Composable +private fun KeyRow( + label: String, + value: String, + isSensitive: Boolean, + modifier: Modifier = Modifier, +) { + var copiedRecently by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = + if (isSensitive) { + Color(0xFFFF5252) + } else { + MaterialTheme.colorScheme.onSurface + }, + fontWeight = FontWeight.Bold, + ) + + Button( + onClick = { + copyToClipboard(value) + copiedRecently = true + }, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (copiedRecently) { + Color(0xFF4CAF50) + } else { + MaterialTheme.colorScheme.primaryContainer + }, + ), + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.padding(end = 4.dp), + ) + Text(if (copiedRecently) "Copied!" else "Copy") + } + } + + Spacer(Modifier.height(4.dp)) + + // Display the key in monospace + Text( + value, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = + if (isSensitive) { + Color(0xFFFF5252) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = + Modifier + .fillMaxWidth() + .background( + color = Color(0xFF1A1A1A), + shape = RoundedCornerShape(4.dp), + ).padding(8.dp), + ) + + // Reset copied state after a delay + if (copiedRecently) { + androidx.compose.runtime.LaunchedEffect(Unit) { + kotlinx.coroutines.delay(2000) + copiedRecently = false + } + } + } +} + +/** + * Copy text to system clipboard using AWT Toolkit. + */ +private fun copyToClipboard(text: String) { + try { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val selection = StringSelection(text) + clipboard.setContents(selection, selection) + } catch (e: Exception) { + e.printStackTrace() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 9eb70e313..0636f2041 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -22,115 +22,271 @@ package com.vitorpamplona.amethyst.desktop.ui 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.fillMaxSize +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.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf +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.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode +import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.ui.note.NoteCard import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener -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.nip02FollowList.ContactListEvent + +/** + * Note card with action buttons. + */ +@Composable +fun FeedNoteCard( + event: Event, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn?, + onReply: () -> Unit, + onNavigateToProfile: (String) -> Unit = {}, +) { + Column { + NoteCard( + note = event.toNoteDisplayData(), + onAuthorClick = onNavigateToProfile, + ) + + // Action buttons (only if logged in) + if (account != null) { + NoteActionsRow( + event = event, + relayManager = relayManager, + account = account, + onReplyClick = onReply, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } +} @Composable -fun FeedScreen(relayManager: DesktopRelayConnectionManager) { +fun FeedScreen( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn? = null, + onCompose: () -> Unit = {}, + onNavigateToProfile: (String) -> Unit = {}, +) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() - val events = remember { mutableStateListOf() } - val seenIds = remember { mutableSetOf() } - - // Use relayStatuses.keys (configured relays) to initiate subscription, - // not connectedRelays (which is empty until subscriptions trigger connections) - val configuredRelays = relayStatuses.keys - - DisposableEffect(configuredRelays) { - if (configuredRelays.isNotEmpty()) { - val subId = "global-feed-${System.currentTimeMillis()}" - val filters = - listOf( - Filter( - kinds = listOf(TextNoteEvent.KIND), - limit = 50, - ), - ) - - relayManager.subscribe( - subId = subId, - filters = filters, - relays = configuredRelays, - listener = - object : IRequestListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event.id !in seenIds) { - seenIds.add(event.id) - events.add(0, event) - if (events.size > 200) { - val removed = events.removeAt(events.size - 1) - seenIds.remove(removed.id) - } - } - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // End of stored events - } - }, + val scope = rememberCoroutineScope() + val eventState = + remember { + EventCollectionState( + getId = { it.id }, + sortComparator = compareByDescending { it.createdAt }, + maxSize = 200, + scope = scope, ) + } + val events by eventState.items.collectAsState() + var replyToEvent by remember { mutableStateOf(null) } + var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } + var followedUsers by remember { mutableStateOf>(emptySet()) } - onDispose { - relayManager.unsubscribe(subId) - } + // Load followed users for Following feed mode + rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { + createContactListSubscription( + relays = configuredRelays, + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, _, _ -> + if (event is ContactListEvent) { + followedUsers = event.verifiedFollowKeySet() + } + }, + ) } else { - onDispose { } + null + } + } + + // Clear events when feed mode changes + remember(feedMode) { + eventState.clear() + } + + // Subscribe to feed based on mode + rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty()) { + return@rememberSubscription null + } + + when (feedMode) { + FeedMode.GLOBAL -> { + createGlobalFeedSubscription( + relays = configuredRelays, + onEvent = { event, _, _, _ -> + eventState.addItem(event) + }, + ) + } + FeedMode.FOLLOWING -> { + if (followedUsers.isNotEmpty()) { + createFollowingFeedSubscription( + relays = configuredRelays, + followedUsers = followedUsers.toList(), + onEvent = { event, _, _, _ -> + eventState.addItem(event) + }, + ) + } else { + null + } + } } } Column(modifier = Modifier.fillMaxSize()) { - FeedHeader( - title = "Global Feed", - connectedRelayCount = connectedRelays.size, - onRefresh = { relayManager.connect() }, - ) + // Header with compose button + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) - Spacer(Modifier.height(16.dp)) + // Feed mode selector + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { feedMode = FeedMode.GLOBAL }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { feedMode = FeedMode.FOLLOWING }, + label = { Text("Following") }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${connectedRelays.size} relays connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (feedMode == FeedMode.FOLLOWING) { + Text( + " • ${followedUsers.size} followed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { relayManager.connect() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } + + // New Post button (primary action) + Button( + onClick = onCompose, + enabled = account != null && !account.isReadOnly, + ) { + Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("New Post") + } + } + + Spacer(Modifier.height(8.dp)) if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") + } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { + LoadingState("Loading followed users...") } else if (events.isEmpty()) { - LoadingState("Loading notes...") + LoadingState( + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users yet" + } else { + "Loading notes..." + }, + ) } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(events, key = { it.id }) { event -> - // Use NoteCard from commons - NoteCard( - note = event.toNoteDisplayData(), + FeedNoteCard( + event = event, + relayManager = relayManager, + account = account, + onReply = { replyToEvent = event }, + onNavigateToProfile = onNavigateToProfile, ) } } } + + // Reply dialog + if (replyToEvent != null && account != null) { + ComposeNoteDialog( + onDismiss = { replyToEvent = null }, + relayManager = relayManager, + account = account, + replyTo = replyToEvent, + ) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 68761246e..be84b6375 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -32,14 +32,19 @@ 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.unit.dp +import com.vitorpamplona.amethyst.commons.SharedRes import com.vitorpamplona.amethyst.commons.account.AccountManager import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.ui.auth.LoginCard import com.vitorpamplona.amethyst.commons.ui.auth.NewKeyWarningCard +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun LoginScreen( @@ -48,6 +53,7 @@ fun LoginScreen( ) { var showNewKeyDialog by remember { mutableStateOf(false) } var generatedAccount by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() Column( modifier = Modifier.fillMaxSize().padding(32.dp), @@ -55,7 +61,7 @@ fun LoginScreen( verticalArrangement = Arrangement.Center, ) { Text( - "Welcome to Amethyst", + stringResource(SharedRes.strings.login_title), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onBackground, ) @@ -63,7 +69,7 @@ fun LoginScreen( Spacer(Modifier.height(8.dp)) Text( - "A Nostr client for desktop", + stringResource(SharedRes.strings.login_subtitle_desktop), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -73,7 +79,11 @@ fun LoginScreen( LoginCard( onLogin = { keyInput -> accountManager.loginWithKey(keyInput).map { - onLoginSuccess() + // Save account to secure storage (use IO dispatcher to avoid blocking UI) + scope.launch(Dispatchers.IO) { + accountManager.saveCurrentAccount() + onLoginSuccess() + } } }, onGenerateNew = { @@ -89,7 +99,11 @@ fun LoginScreen( nsec = generatedAccount!!.nsec, onContinue = { showNewKeyDialog = false - onLoginSuccess() + // Save generated account (use IO dispatcher to avoid blocking UI) + scope.launch(Dispatchers.IO) { + accountManager.saveCurrentAccount() + onLoginSuccess() + } }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt new file mode 100644 index 000000000..fcc12c918 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.icons.Reply +import com.vitorpamplona.amethyst.commons.icons.Repost +import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction +import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Action buttons row for a note (react, reply, repost). + */ +@Composable +fun NoteActionsRow( + event: Event, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, + onReplyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var isLiked by remember { mutableStateOf(false) } + var isReposted by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Reply button + IconButton( + onClick = onReplyClick, + modifier = Modifier.size(32.dp), + ) { + Icon( + Reply, + contentDescription = "Reply", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + + // Like button + IconButton( + onClick = { + if (!isLiked) { + scope.launch { + reactToNote( + event = event, + reaction = "+", + account = account, + relayManager = relayManager, + ) + isLiked = true + } + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = if (isLiked) "Unlike" else "Like", + tint = + if (isLiked) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + + // Repost button + IconButton( + onClick = { + if (!isReposted) { + scope.launch { + repostNote( + event = event, + account = account, + relayManager = relayManager, + ) + isReposted = true + } + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Repost, + contentDescription = "Repost", + tint = + if (isReposted) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + + // Placeholder for action count + Text( + "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Creates a reaction event and broadcasts to relays. + */ +private suspend fun reactToNote( + event: Event, + reaction: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +) { + withContext(Dispatchers.IO) { + // Use shared ReactionAction from commons + val signedEvent = ReactionAction.reactTo(event, reaction, account.signer) + + // Broadcast to all relays + relayManager.broadcastToAll(signedEvent) + } +} + +/** + * Creates a repost event and broadcasts to relays. + */ +private suspend fun repostNote( + event: Event, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +) { + withContext(Dispatchers.IO) { + // Use shared RepostAction from commons + val signedEvent = RepostAction.repost(event, account.signer) + + // Broadcast to all relays + relayManager.broadcastToAll(signedEvent) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt new file mode 100644 index 000000000..772f05219 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -0,0 +1,283 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +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.fillMaxSize +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.icons.Reply +import com.vitorpamplona.amethyst.commons.icons.Repost +import com.vitorpamplona.amethyst.commons.icons.Zap +import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.subscriptions.createNotificationsSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader +import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent + +/** + * Notification types for display. + */ +sealed class NotificationItem( + open val event: Event, + open val timestamp: Long, +) { + data class Mention( + override val event: Event, + override val timestamp: Long, + ) : NotificationItem(event, timestamp) + + data class Reply( + override val event: Event, + override val timestamp: Long, + ) : NotificationItem(event, timestamp) + + data class Reaction( + override val event: Event, + override val timestamp: Long, + val content: String, + ) : NotificationItem(event, timestamp) + + data class Repost( + override val event: Event, + override val timestamp: Long, + ) : NotificationItem(event, timestamp) + + data class Zap( + override val event: Event, + override val timestamp: Long, + val amount: Long?, + ) : NotificationItem(event, timestamp) +} + +@Composable +fun NotificationsScreen( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val scope = rememberCoroutineScope() + val notificationState = + remember { + EventCollectionState( + getId = { it.event.id }, + sortComparator = null, // Prepend new items (no sorting) + maxSize = 200, + scope = scope, + ) + } + val notifications by notificationState.items.collectAsState() + + // Subscribe to notifications + rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + createNotificationsSubscription( + relays = configuredRelays, + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, _, _ -> + // Skip events from the user themselves (except zaps) + if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) { + return@createNotificationsSubscription + } + + val notification = + when (event) { + is ReactionEvent -> + NotificationItem.Reaction( + event = event, + timestamp = event.createdAt, + content = event.content, + ) + is RepostEvent, is GenericRepostEvent -> + NotificationItem.Repost( + event = event, + timestamp = event.createdAt, + ) + is LnZapEvent -> { + val amount = event.amount?.toLong() + NotificationItem.Zap( + event = event, + timestamp = event.createdAt, + amount = amount, + ) + } + is TextNoteEvent -> { + val eTags = event.tags.filter { it.size > 1 && it[0] == "e" } + val isReply = eTags.isNotEmpty() + if (isReply) { + NotificationItem.Reply(event, event.createdAt) + } else { + NotificationItem.Mention(event, event.createdAt) + } + } + else -> NotificationItem.Mention(event, event.createdAt) + } + + notificationState.addItem(notification) + }, + ) + } else { + null + } + } + + Column(modifier = Modifier.fillMaxSize()) { + FeedHeader( + title = "Notifications", + connectedRelayCount = connectedRelays.size, + onRefresh = { relayManager.connect() }, + ) + + Spacer(Modifier.height(16.dp)) + + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (notifications.isEmpty()) { + LoadingState("Loading notifications...") + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(notifications, key = { it.event.id }) { notification -> + NotificationCard(notification) + } + } + } + } +} + +@Composable +fun NotificationCard(notification: NotificationItem) { + val (icon, label, color) = + when (notification) { + is NotificationItem.Mention -> Triple(Icons.Default.Favorite, "mentioned you", MaterialTheme.colorScheme.primary) + is NotificationItem.Reply -> Triple(Reply, "replied", MaterialTheme.colorScheme.secondary) + is NotificationItem.Reaction -> + Triple( + Icons.Default.Favorite, + "reacted ${notification.content}", + MaterialTheme.colorScheme.tertiary, + ) + is NotificationItem.Repost -> Triple(Repost, "reposted", MaterialTheme.colorScheme.primary) + is NotificationItem.Zap -> { + val amountText = notification.amount?.let { " ${it / 1000} sats" } ?: "" + Triple(Zap, "zapped$amountText", MaterialTheme.colorScheme.primary) + } + } + + val authorDisplay = + try { + notification.event.pubKey + .hexToByteArrayOrNull() + ?.toNpub() + ?.take(20) ?: notification.event.pubKey.take(20) + } catch (e: Exception) { + notification.event.pubKey.take(20) + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + // Header: icon + label + author + time + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + icon, + contentDescription = label, + tint = color, + modifier = Modifier.size(16.dp), + ) + Text( + text = "$authorDisplay $label", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = notification.timestamp.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + + // Content (for text-based notifications) + if (notification is NotificationItem.Mention || + notification is NotificationItem.Reply + ) { + Spacer(Modifier.height(8.dp)) + Text( + text = notification.event.content.take(200), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt new file mode 100644 index 000000000..475fe55b1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -0,0 +1,535 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction +import com.vitorpamplona.amethyst.commons.state.EventCollectionState +import com.vitorpamplona.amethyst.commons.state.FollowState +import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createMetadataSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.createUserPostsSubscription +import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * User profile screen showing user info, follow button, and their posts. + */ +@Composable +fun UserProfileScreen( + pubKeyHex: String, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn?, + onBack: () -> Unit, + onCompose: () -> Unit = {}, + onNavigateToProfile: (String) -> Unit = {}, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + + // User metadata + var displayName by remember { mutableStateOf(null) } + var about by remember { mutableStateOf(null) } + var picture by remember { mutableStateOf(null) } + var followersCount by remember { mutableStateOf(0) } + var followingCount by remember { mutableStateOf(0) } + + val scope = rememberCoroutineScope() + + // User's posts + val eventState = + remember { + EventCollectionState( + getId = { it.id }, + sortComparator = compareByDescending { it.createdAt }, + maxSize = 200, + scope = scope, + ) + } + val events by eventState.items.collectAsState() + var postsLoading by remember { mutableStateOf(true) } + var postsError by remember { mutableStateOf(null) } + var retryTrigger by remember { mutableStateOf(0) } + + // Follow state + val followState = + remember(account) { + FollowState(myPubKeyHex = account?.pubKeyHex ?: "") + } + + // Store the user's contact list separately for reliable access + var myContactList by remember(account) { mutableStateOf(null) } + var contactListLoaded by remember(account) { mutableStateOf(false) } + var eoseReceivedCount by remember(account) { mutableStateOf(0) } + + // Load current user's contact list (for follow state) + rememberSubscription(relayStatuses, account, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + createContactListSubscription( + relays = configuredRelays, + pubKeyHex = account.pubKeyHex, + onEvent = { event, _, _, _ -> + if (event is ContactListEvent) { + // Store the most recent contact list (by createdAt timestamp) + if (myContactList == null || event.createdAt > myContactList!!.createdAt) { + myContactList = event + } + + followState.updateContactList(event, pubKeyHex) + contactListLoaded = true + } + }, + onEose = { _, _ -> + eoseReceivedCount++ + + // Wait for EOSE from at least 2 relays or all relays before enabling button + val minEoseCount = minOf(2, configuredRelays.size) + if (eoseReceivedCount >= minEoseCount && !contactListLoaded) { + contactListLoaded = true + } + }, + ) + } else { + null + } + } + + // Clear posts when profile changes + remember(pubKeyHex, retryTrigger) { + eventState.clear() + postsLoading = true + postsError = null + } + + // Subscribe to user metadata + rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + createMetadataSubscription( + relays = configuredRelays, + pubKeyHex = pubKeyHex, + onEvent = { event, _, _, _ -> + try { + val content = event.content + displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name") + about = extractJsonField(content, "about") + picture = extractJsonField(content, "picture") + } catch (e: Exception) { + // Ignore parse errors + } + }, + ) + } else { + null + } + } + + // Subscribe to user posts + rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + postsLoading = true + postsError = null + createUserPostsSubscription( + relays = configuredRelays, + pubKeyHex = pubKeyHex, + onEvent = { event, _, _, _ -> + eventState.addItem(event) + }, + onEose = { _, _ -> + postsLoading = false + }, + ) + } else { + postsLoading = false + postsError = "No relays configured" + null + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Header with back button + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Spacer(Modifier.width(8.dp)) + Text( + "Profile", + style = MaterialTheme.typography.headlineMedium, + ) + } + + if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { + Column(horizontalAlignment = Alignment.End) { + Button( + onClick = { + scope.launch { + val currentStatus = followState.currentStatusOrNull() + + followState.setFollowLoading() + try { + val updatedEvent = + if (currentStatus?.isFollowing == true) { + unfollowUser(pubKeyHex, account, relayManager, myContactList) + } else { + followUser(pubKeyHex, account, relayManager, myContactList) + } + + // Update both stored contact list and followState + myContactList = updatedEvent + followState.setFollowSuccess(updatedEvent, pubKeyHex) + } catch (e: Exception) { + e.printStackTrace() + followState.setFollowError(e.message ?: "Failed to update follow status", e) + } + } + }, + enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, + ) { + val state = followState.state.collectAsState().value + val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false + val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading + + when { + !contactListLoaded -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Loading...") + } + isLoading -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollowing..." else "Following...") + } + else -> { + Icon( + if (isFollowing) Icons.Default.PersonRemove else Icons.Default.PersonAdd, + contentDescription = if (isFollowing) "Unfollow" else "Follow", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollow" else "Follow") + } + } + } + + val errorMessage = + followState.state + .collectAsState() + .value + .errorOrNull() + errorMessage?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else { + // Profile card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + // Profile picture with robohash fallback + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 56.dp, + contentDescription = "Profile picture", + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(32) ?: pubKeyHex.take(32)) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (about != null) { + Spacer(Modifier.height(12.dp)) + Text( + about!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column { + Text( + "$followersCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Followers", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column { + Text( + "$followingCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Following", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + Spacer(Modifier.height(16.dp)) + + // User's posts + Text( + "Posts", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + + when { + postsError != null -> { + // Error state with retry + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Failed to load posts", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Text( + postsError!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { retryTrigger++ }) { + Text("Retry") + } + } + } + } + postsLoading -> { + // Loading state + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material3.CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Loading posts...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + events.isEmpty() -> { + // Empty state (loaded but no posts) + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No posts yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + else -> { + // Posts loaded successfully + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(events, key = { it.id }) { event -> + FeedNoteCard( + event = event, + relayManager = relayManager, + account = account, + onReply = onCompose, + onNavigateToProfile = onNavigateToProfile, + ) + } + } + } + } + } + } +} + +/** + * Simple JSON field extractor (not production-ready, just for demo). + */ +private fun extractJsonField( + json: String, + field: String, +): String? { + val regex = """"$field"\s*:\s*"([^"]*)"""".toRegex() + return regex.find(json)?.groupValues?.get(1) +} + +/** + * Follows a user by publishing an updated contact list event. + */ +private suspend fun followUser( + pubKeyHex: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + currentContactList: ContactListEvent?, +): ContactListEvent = + withContext(Dispatchers.IO) { + // Use shared FollowAction from commons + val updatedEvent = FollowAction.follow(pubKeyHex, account.signer, currentContactList) + + val connectedRelays = relayManager.connectedRelays.value + if (connectedRelays.isEmpty()) { + throw IllegalStateException("Cannot follow: No connected relays") + } + + relayManager.broadcastToAll(updatedEvent) + + updatedEvent + } + +/** + * Unfollows a user by publishing an updated contact list event without them. + */ +private suspend fun unfollowUser( + pubKeyHex: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + currentContactList: ContactListEvent?, +): ContactListEvent = + withContext(Dispatchers.IO) { + if (currentContactList != null) { + // Use shared FollowAction from commons + val updatedEvent = FollowAction.unfollow(pubKeyHex, account.signer, currentContactList) + + val connectedRelays = relayManager.connectedRelays.value + if (connectedRelays.isEmpty()) { + throw IllegalStateException("Cannot unfollow: No connected relays") + } + + relayManager.broadcastToAll(updatedEvent) + + updatedEvent + } else { + throw IllegalStateException("Cannot unfollow: No contact list available") + } + } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfigTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfigTest.kt new file mode 100644 index 000000000..90e2b34d4 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfigTest.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.desktop + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DebugConfigTest { + @Test + fun testDebugModeCanBeQueried() { + // This test verifies that DebugConfig.isDebugMode is accessible + // The actual value depends on environment/system properties + val debugMode = DebugConfig.isDebugMode + // Just verify it returns a boolean (true or false) + assertTrue(debugMode || !debugMode, "isDebugMode should return a boolean value") + } + + @Test + fun testDebugLogDoesNotThrow() { + // Verify debug logging doesn't throw exceptions + try { + DebugConfig.log("Test debug message") + assertTrue(true) + } catch (e: Exception) { + assertFalse(true, "Debug log should not throw exceptions: ${e.message}") + } + } + + @Test + fun testDebugConfigIsObject() { + // Verify DebugConfig is a singleton object + val instance1 = DebugConfig + val instance2 = DebugConfig + assertTrue(instance1 === instance2, "DebugConfig should be a singleton object") + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.kt new file mode 100644 index 000000000..ef29957c0 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClientTest.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.desktop.network + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DesktopHttpClientTest { + @Test + fun testGetHttpClientReturnsConfiguredClient() { + val url = NormalizedRelayUrl("wss://relay.damus.io") + val client = DesktopHttpClient.getHttpClient(url) + + assertNotNull(client) + assertEquals(30_000, client.connectTimeoutMillis) + assertEquals(30_000, client.readTimeoutMillis) + assertEquals(30_000, client.writeTimeoutMillis) + assertEquals(30_000, client.pingIntervalMillis) + assertTrue(client.retryOnConnectionFailure) + } + + @Test + fun testGetHttpClientReturnsSameInstance() { + val url1 = NormalizedRelayUrl("wss://relay.damus.io") + val url2 = NormalizedRelayUrl("wss://nos.lol") + + val client1 = DesktopHttpClient.getHttpClient(url1) + val client2 = DesktopHttpClient.getHttpClient(url2) + + // Should return the same singleton instance + assertEquals(client1, client2) + } + + @Test + fun testHttpClientHasExpectedTimeouts() { + val url = NormalizedRelayUrl("wss://relay.nostr.band") + val client = DesktopHttpClient.getHttpClient(url) + + // Verify all timeouts are 30 seconds + assertEquals(30_000, client.connectTimeoutMillis) + assertEquals(30_000, client.readTimeoutMillis) + assertEquals(30_000, client.writeTimeoutMillis) + assertEquals(30_000, client.pingIntervalMillis) + } + + @Test + fun testHttpClientHasRetryEnabled() { + val url = NormalizedRelayUrl("wss://relay.snort.social") + val client = DesktopHttpClient.getHttpClient(url) + + assertTrue(client.retryOnConnectionFailure, "Retry on connection failure should be enabled") + } + + @Test + fun testHttpClientIsLazyInitialized() { + // This test verifies the lazy initialization pattern + // The client should be created only once even with multiple calls + val url1 = NormalizedRelayUrl("wss://relay1.example.com") + val url2 = NormalizedRelayUrl("wss://relay2.example.com") + val url3 = NormalizedRelayUrl("wss://relay3.example.com") + + val client1 = DesktopHttpClient.getHttpClient(url1) + val client2 = DesktopHttpClient.getHttpClient(url2) + val client3 = DesktopHttpClient.getHttpClient(url3) + + // All should be the same instance due to lazy singleton + assertEquals(client1, client2) + assertEquals(client2, client3) + assertEquals(client1, client3) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.kt new file mode 100644 index 000000000..3075e610a --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManagerTest.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.desktop.network + +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DesktopRelayConnectionManagerTest { + @Test + fun testRelayConnectionManagerCanBeInstantiated() { + val manager = DesktopRelayConnectionManager() + assertNotNull(manager) + } + + @Test + fun testRelayConnectionManagerHasNoActiveConnectionsInitially() { + val manager = DesktopRelayConnectionManager() + val connectedRelays = manager.connectedRelays.value + val availableRelays = manager.availableRelays.value + assertTrue(connectedRelays.isEmpty(), "Should have no connected relays on initialization") + assertTrue(availableRelays.isEmpty(), "Should have no available relays on initialization") + } + + @Test + fun testRelayConnectionManagerInheritsFromBaseClass() { + val manager = DesktopRelayConnectionManager() + assertTrue( + manager is com.vitorpamplona.amethyst.commons.network.RelayConnectionManager, + "DesktopRelayConnectionManager should extend RelayConnectionManager", + ) + } +} diff --git a/docs/secure-key-storage-migration.md b/docs/secure-key-storage-migration.md new file mode 100644 index 000000000..bec77ca40 --- /dev/null +++ b/docs/secure-key-storage-migration.md @@ -0,0 +1,208 @@ +# SecureKeyStorage Migration Guide + +## Overview + +SecureKeyStorage is now in `quartz/nip01Core/crypto/` as a KMP module for secure nsec (private key) storage. + +**Location:** `com.vitorpamplona.quartz.nip01Core.crypto.SecureKeyStorage` + +## Platform Implementations + +| Platform | Backend | Encryption | Hardware-Backed | +|----------|---------|------------|-----------------| +| **Android** | EncryptedSharedPreferences + Android Keystore | AES-256-GCM | ✅ (StrongBox when available) | +| **Desktop macOS** | macOS Keychain (via java-keyring) | OS-managed | ✅ (T2/M1+ chips) | +| **Desktop Windows** | Credential Manager (via java-keyring) | DPAPI | ✅ (TPM when available) | +| **Desktop Linux** | Secret Service/KWallet (via java-keyring) | OS-managed | ⚠️ (depends on setup) | +| **Fallback** | Encrypted file (~/.amethyst/keys.enc) | AES-256-GCM (PBKDF2) | ❌ | + +## API + +```kotlin +// Create instance (Android requires Context) +val storage = SecureKeyStorage(context) // Android +val storage = SecureKeyStorage() // Desktop + +// Save private key +suspend fun savePrivateKey(npub: String, privKeyHex: String) + +// Retrieve private key +suspend fun getPrivateKey(npub: String): String? + +// Delete private key +suspend fun deletePrivateKey(npub: String): Boolean + +// Check if exists +suspend fun hasPrivateKey(npub: String): Boolean +``` + +## Migrating Amethyst + +### Current Implementation + +**File:** `amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt` + +- Uses `EncryptedSharedPreferences` directly +- Private keys stored in per-account encrypted prefs: `secret_keeper_$npub` +- Key: `NOSTR_PRIVKEY` + +**Current flow:** +```kotlin +// Save +val prefs = EncryptedStorage.preferences(context, npub) +prefs.edit().putString(PrefKeys.NOSTR_PRIVKEY, privKeyHex).apply() + +// Load +val privKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) +``` + +### New Implementation + +**Import:** +```kotlin +import com.vitorpamplona.quartz.nip01Core.crypto.SecureKeyStorage +import com.vitorpamplona.quartz.nip01Core.crypto.SecureStorageException +``` + +**New flow:** +```kotlin +// Initialize once (in Application or DI module) +val secureStorage = SecureKeyStorage(applicationContext) + +// Save +suspend fun saveAccount(npub: String, privKeyHex: String) { + try { + secureStorage.savePrivateKey(npub, privKeyHex) + } catch (e: SecureStorageException) { + Log.e("Account", "Failed to save key", e) + } +} + +// Load +suspend fun loadAccount(npub: String): String? { + return try { + secureStorage.getPrivateKey(npub) + } catch (e: SecureStorageException) { + Log.e("Account", "Failed to load key", e) + null + } +} + +// Delete +suspend fun deleteAccount(npub: String) { + secureStorage.deletePrivateKey(npub) +} +``` + +### Migration Steps + +1. **Update LocalPreferences.kt:** + - Add `SecureKeyStorage` instance + - Replace `EncryptedStorage.preferences()` calls with `SecureKeyStorage` methods + - Wrap in coroutines (`withContext(Dispatchers.IO)`) + +2. **Update AccountSecretsEncryptedStores.kt:** + - Already uses DataStore - can be replaced or kept for NWC secrets + - Consider using `SecureKeyStorage` for consistency + +3. **Handle Existing Keys:** + - **Option A (Automatic Migration):** On first launch, read from old storage → save to new storage → delete old + - **Option B (Keep Dual Storage):** Keep old code for read, use new for writes, phase out old storage later + - **Option C (Clean Break):** Require users to re-enter nsec on first launch after update + +4. **Desktop Integration:** + - Create `SecureKeyStorage()` instance (no context needed) + - Use same API calls as Android + - Fallback password prompt if OS keyring unavailable (auto-triggered) + +### Example Migration (Option A) + +```kotlin +// In LocalPreferences +private val secureStorage = SecureKeyStorage(applicationContext) + +suspend fun migrateToNewStorage(npub: String) { + // Check if already migrated + if (secureStorage.hasPrivateKey(npub)) return + + // Read from old storage + val oldPrefs = EncryptedStorage.preferences(applicationContext, npub) + val privKey = oldPrefs.getString(PrefKeys.NOSTR_PRIVKEY, null) ?: return + + // Save to new storage + secureStorage.savePrivateKey(npub, privKey) + + // Clean up old storage (optional) + oldPrefs.edit().remove(PrefKeys.NOSTR_PRIVKEY).apply() +} + +suspend fun loadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? { + npub ?: return null + + // Auto-migrate if needed + migrateToNewStorage(npub) + + // Load from new storage + val privKey = secureStorage.getPrivateKey(npub) + + // ... rest of loading logic +} +``` + +## Desktop Fallback Behavior + +If OS keyring unavailable (headless server, permission denied): + +1. User prompted for master password on first `savePrivateKey()` call +2. Password cached in memory for session +3. Keys stored encrypted in `~/.amethyst/keys.enc` +4. Format: `:` (one per line) +5. Encryption: AES-256-GCM with PBKDF2-derived key (100k iterations) + +**Security Note:** Fallback is less secure than OS keyring. Warn users to use OS-integrated keychain when possible. + +## Testing + +### Android +```kotlin +@Test +fun testSecureStorage() = runTest { + val storage = SecureKeyStorage(context) + val npub = "npub1test..." + val privKey = "nsec1test..." + + storage.savePrivateKey(npub, privKey) + assertEquals(privKey, storage.getPrivateKey(npub)) + assertTrue(storage.hasPrivateKey(npub)) + + storage.deletePrivateKey(npub) + assertFalse(storage.hasPrivateKey(npub)) +} +``` + +### Desktop +```kotlin +@Test +fun testDesktopStorage() = runTest { + val storage = SecureKeyStorage() + + // Test with OS keyring (if available) + storage.savePrivateKey("npub1test...", "nsec1test...") + assertNotNull(storage.getPrivateKey("npub1test...")) +} +``` + +## Security Considerations + +- **Android:** Keys protected by hardware when available (TEE/StrongBox) +- **Desktop:** Keys stored in OS-native credential managers (encrypted at rest) +- **Fallback:** User-password-protected file (PBKDF2 + AES-256-GCM) +- **Never log private keys** - use `SecureStorageException` for errors +- **Clear from memory** - keys returned as strings (consider SecureString for future enhancement) + +## References + +- Android Implementation: `quartz/src/androidMain/kotlin/.../SecureKeyStorage.kt` +- Desktop Implementation: `quartz/src/jvmMain/kotlin/.../SecureKeyStorage.kt` +- java-keyring: https://github.com/javakeyring/java-keyring +- Android EncryptedSharedPreferences: https://developer.android.com/topic/security/data diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c30406e97..972488de6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ firebaseBom = "34.7.0" fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.20.1" +javaKeyring = "1.0.1" jna = "5.18.1" jtorctl = "0.4.5.7" junit = "4.13.2" @@ -40,6 +41,7 @@ lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" media3 = "1.9.0" mockk = "1.14.7" +mokoResources = "0.25.2" kotlinx-coroutines-test = "1.10.2" navigationCompose = "2.9.6" okhttp = "5.3.2" @@ -52,7 +54,7 @@ torAndroid = "0.4.8.21.1" translate = "17.0.3" unifiedpush = "3.0.10" urlDetector = "0.1.23" -vico-charts = "2.3.6" +vico-charts = "2.4.0" zelory = "3.0.1" zoomable = "2.9.0" zxing = "3.5.4" @@ -120,6 +122,7 @@ 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" } +java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } 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" } @@ -135,6 +138,8 @@ markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "ric markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } +moko-resources = { group = "dev.icerock.moko", name = "resources", version.ref = "mokoResources" } +moko-resources-compose = { group = "dev.icerock.moko", name = "resources-compose", version.ref = "mokoResources" } 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" } @@ -171,5 +176,6 @@ serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } -stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" } +stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.6" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 0a1b35412..68adad906 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -303,7 +303,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.04.2" + version = "1.05.1" ) // Configure publishing to Maven Central diff --git a/quartz/relayDB.txt b/quartz/relayDB.txt deleted file mode 100644 index 0977f44b3..000000000 --- a/quartz/relayDB.txt +++ /dev/null @@ -1,2539 +0,0 @@ -wss://nostr.wine -wss://relay.orangepill.dev -wss://xmr.usenostr.org -wss://nostr.portemonero.com -wss://nostr.xmr.rocks -wss://relay.nostr.band -wss://filter.nostr.wine -wss://nostr.milou.lol -wss://nostr.mutinywallet.com -wss://nostr-pub.wellorder.net -wss://nostr.zebedee.cloud -wss://nos.lol -wss://brb.io -wss://bitcoiner.social -wss://nostr.decentony.com -wss://relay.nostriches.org -wss://paid.spore.ws -wss://eden.nostr.land -wss://puravida.nostr.land -wss://5dzvuefllevkhk7miqynaviguedxfnofrayu2xwfwtlkdg4radjdlyqd.onion -wss://relay-jp.nostr.wirednet.jp -wss://relay.nostrich.land -wss://nostr.holybea.com -wss://nostr-relay.nokotaro.com -wss://nostr-paid.h3z.jp -wss://nostrja-kari.heguro.com -wss://nostr.mom -wss://nostr.fediverse.jp -wss://nostr.h3z.jp -wss://universe.nostrich.land -wss://nostr.uselessshit.co -wss://atlas.nostr.land -wss://relay.snort.social -wss://universe.nostrich.landlangenlanges -wss://nostr.slothy.win -wss://nostr.plebchain.org -wss://nostr-relay.untethr.me -wss://relay.nostr.com.au -wss://nostr.inosta.cc -wss://relay.nostrati.com -wss://nostr.bitcoiner.social -wss://relay.nostrplebs.com -wss://relay.nostr.info -wss://nostr-relay.wlvs.space -wss://nostr.oxtr.dev -wss://nostr.onsats.org -wss://relay.wellorder.net -wss://relay.plebstr.com -wss://no.str.cr -wss://nostr.walletofsatoshi.com -wss://nostr.mwmdev.com -wss://relay.nostr.bg -wss://nostr.rocks -wss://nostr.fmt.wiz.biz -wss://nostr.orangepill.dev -wss://nostr-pub.semisol.dev -wss://nostr.sandwich.farm -wss://relay.nostr.ch -wss://relay.orange-crush.com -wss://private.red.gb.net -wss://nostr.lnprivate.network -wss://nostr.lu.ke -wss://relay.nostr.wirednet.jp -wss://lightningrelay.com -wss://relay.nostrgraph.net -wss://relay.nostrica.com -wss://relay.mostr.pub -wss://nostr-sg.com -wss://nostr.zkid.social -wss://relay.nostr.vet -wss://relay.nostr3.io -wss://relay.arsip.my.id -wss://relay.current.fyi -wss://global.relay.red -wss://nostr.island.network -wss://node01.nostress.cc -wss://relay.nostr.net.in -wss://relay.utxo.one -wss://relay-1.arsip.my.id -wss://nostrical.com -wss://nostro.cc -wss://rsslay.nostr.moe -wss://relay.nostr.or.jp -wss://nostream.unift.xyz -wss://blastr.f7z.xyz -wss://relay.honk.pw -wss://universe.nostrich.landlangja -wss://nostream.ocha.one -wss://paid-relay.nost.love -wss://offchain.pub -wss://nostr-usa.ka1gbeoa21bnm.us-west-2.cs.amazonlightsail.com -wss://nostr.terminus.money -wss://nostr.shawnyeager.net -wss://public.nostr.swissrouting.com -wss://nostrex.fly.dev -wss://relay.727whisky.com -wss://relay.cryptocculture.com -wss://relay.bleskop.com -wss://nostr.lorentz.is -wss://nostr.actn.io -wss://nostr-relay.lnmarkets.com -wss://nostr.openchain.fr -wss://relay.punkhub.me -wss://nostr.blipme.app -wss://nostr.swiss-enigma.ch -wss://nostr-verified.wellorder.net -wss://at.nostrworks.com -wss://21sats.net -wss://e.nos.lol -wss://nostr.mikedilger.com -wss://nostr.azte.co -wss://noster.bitcoiner.social -wss://relay.nostr.moe -wss://nostream.nostrly.io -wss://nostr.gives.africa -wss://nostr.21sats.net -wss://bitcoinmaximalists.online -wss://paid.nostrified.org -wss://nostr.1sat.org -wss://nostr.relayer.se -wss://sg.qemura.xyz -wss://nostr.coinos.io -wss://nostr.bitcoinplebs.de -wss://nostrich.friendship.tw -wss://nostr.sg -wss://eosla.com -wss://nostr.sidnlabs.nl -wss://nostr.foundrydigital.com -wss://relay.nostrview.com -wss://nostr.semisol.dev -wss://relay.f7z.io -wss://wlvs.space -wss://nostr.v0l.io -wss://nostr-relay.digitalmob.ro -wss://rsslay.fiatjaf.com -wss://relay.theorangepillapp.com -wss://relay.zeh.app -wss://nostr.zoomout.chat -wss://relay.stoner.com -wss://nostr.cercatrova.me -wss://relay.ryzizub.com -wss://nostr-1.nbo.angani.co -wss://nostr21.com -wss://spore.ws -wss://nostrue.com -wss://no-str.org -wss://relay.taxi -wss://ragnar-relay.com -wss://relay.austrich.net -wss://relay.nostr-latam.link -wss://1.noztr.com -wss://relay.nostr.scot -wss://test.relay.nostrich.day -wss://jiggytom.ddns.net -wss://nostr.bongbong.com -wss://relay.nostromo.social -wss://relay.sendstr.com -wss://nostr-dev.universalname.space -wss://relay.nostrcheck.me -wss://nostr.libertasprimordium.com -wss://nostr.kollider.xyz -wss://expensive-relay.fiatjaf.com -wss://nostr-sandbox.minds.io -wss://relay.nostrich.de -wss://nostr.gromeul.eu -wss://relay.nostr.wine -wss://nostr.screaminglife.io -wss://nostr-relay.derekross.me -wss://nostrica.nostrnotes.com -wss://paid.no.str.cr -wss://nostr.sethforprivacy.com -wss://nostr.dumpit.top -wss://nostr.herci.one -wss://cheery-paddock-rsakdrtc35c55n6yregn.wnext.app -wss://nostr.blockpower.capital -wss://nostr.nym.life -wss://nostr-verif.slothy.win -wss://fiatdenier.com -wss://nostr.bitcoin-21.org -wss://nostr.fluidtrack.in -wss://nostr.developer.li -wss://r.ayit.org -wss://relay.nostr.nu -wss://nostr.bostonbtc.com -wss://rly.social -wss://nostr.bridgey.dev -wss://relay.nostrprotocol.net -wss://nostr.mado.io -wss://nostr.einundzwanzig.space -wss://nostr2.actn.io -wss://nostr-relay.freedomnode.com -wss://nostr.pleb.network -wss://nostr.mouton.dev -wss://eelay.current.fyi -wss://nostr.notmyhostna.me -wss://nostr.pjv.me -wss://nostr.jatm.link -wss://nostr.fractalized.ovh -wss://nostr-relay.app.ikeji.ma -wss://relayer.ocha.one -wss://nostr.com.de -wss://nostr-2.afarazit.eu -wss://nostr.l00p.org -wss://nostr.drss.io -wss://relay.nostrify.io -wss://nostr.radixrat.com -wss://nostr-relay.bitcoin.ninja -wss://nostrsatva.net -wss://nostr.mustardnodes.com -wss://nostr01.vida.dev -wss://nostr.noones.com -wss://nostr.easify.de -wss://nostr3.actn.io -wss://moonbreeze.richardbondi.net -wss://nostr.naut.social -wss://private-nostr.v0l.io -wss://nostr.zaprite.io -wss://nostr.lightninglinks.xyz -wss://nostr.hackerman.pro -wss://nr.yay.so -wss://nostr.roundrockbitcoiners.com -wss://nostr.sovbit.host -wss://nostrelay.yeghro.site -wss://pow32.nostr.land -wss://nostr.1729.cloud -wss://nostr.rdfriedl.com -wss://nostr.h4x0r.host -wss://nostr.up.railway.app -wss://nostr.lnorb.com -wss://nostr.lordkno.ws -wss://relay.nostr.vision -wss://nostr-3.orba.ca -wss://satstacker.cloud -wss://freedom-relay.herokuapp.com -wss://nostr-relay.freeberty.net -wss://nostr.unknown.place -wss://nostr.delo.software -wss://relay.nostr.pro -wss://relay.minds.com -wss://nostr.ono.re -wss://relay.grunch.dev -wss://relay.cynsar.foundation -wss://relay.oldcity-bitcoiners.info -wss://relay.bitid.nz -wss://relay.nostr.xyz -wss://relay.futohq.com -wss://relay.farscapian.com -wss://astral.ninja -wss://relay.sovereign-stack.org -wss://nostr-2.zebedee.cloud -wss://nostr.nymsrelay.com -wss://relay.kronkltd.net -wss://relay.r3d.red -wss://universe.nostrich.landlangen -wss://nostr-dev.wellorder.net -wss://nostr.beta3.dev -wss://nostr.data.haus -wss://nostr.hugo.md -wss://relay-dev.cowdle.gg -wss://relay.dwadziesciajeden.pl -wss://tmp-relay.cesc.trade -wss://nostr.massmux.com -wss://relay.nostr.africa -wss://nostr1.tunnelsats.com -wss://nostr.f44.dev -wss://relay.n057r.club -wss://nostr-verif.slothy.com -wss://nostr.1f52b.xyz -wss://nostr.sebastix.dev -wss://nostr.lightning.contact -wss://nostr.rly.social -wss://noster.online -wss://relay.lexingtonbitcoin.org -wss://nostr.bitcoinbay.engineering -wss://nostr.howtobitcoin.shop -wss://blg.nostr.sx -wss://deschooling.us -wss://foolay.nostr.moe -wss://freespeech.casa -wss://nostr-01.bolt.observer -wss://nostr-01.dorafactory.org -wss://nostr-au.coinfundit.com -wss://nostr-eu.coinfundit.com -wss://nostr-relay.alekberg.net -wss://nostr-pub1.southflorida.ninja -wss://nostr-relay.gkbrk.com -wss://nostr-relay.pcdkd.fyi -wss://nostr-relay.schnitzel.world -wss://nostr-us.coinfundit.com -wss://nostr.21crypto.ch -wss://nostr.600.wtf -wss://nostr.8e23.net -wss://nostr.app.runonflux.io -wss://nostr.arguflow.gg -wss://nostr.bch.ninja -wss://nostr.chainofimmortals.net -wss://nostr.cizmar.net -wss://nostr.cheeserobot.org -wss://nostr.coollamer.com -wss://nostr.corebreach.com -wss://nostr.cro.social -wss://nostr.easydns.ca -wss://nostr.globals.fans -wss://nostr.handyjunky.com -wss://nostr.itas.li -wss://nostr.sectiontwo.org -wss://nostr.spleenrider.one -wss://nostr.thibautrey.fr -wss://nostr.uthark.com -wss://nostr.vulpem.com -wss://nostr.w3ird.tech -wss://nostr.whoop.ph -wss://nostr.yuv.al -wss://nostr01.opencult.com -wss://nostre.cc -wss://nostream.denizenid.com -wss://nostring.deno.dev -wss://pow.nostrati.com -wss://relay-pub.deschooling.us -wss://nostr.jiashanlu.synology.me -wss://nostr.klabo.blog -wss://relay.valireum.net -wss://nostr.fly.dev -wss://nostr.nordlysln.net -wss://nostr.zerofeerouting.com -wss://rsslay.nostr.net -wss://nostr-relay.nonce.academy -wss://nostr.rewardsbunny.com -wss://lv01.tater.ninja -wss://nostr-2.orba.ca -wss://nostr.orba.ca -wss://nostr.supremestack.xyz -wss://nostrrelay.com -wss://relay.nostr.au -wss://nostr.oooxxx.ml -wss://nostr.yael.at -wss://nostr-relay.trustbtc.org -wss://nostr.namek.link -wss://nostr-relay.wolfandcrow.tech -wss://nostr.satsophone.tk -wss://relay.dev.kronkltd.net -wss://nostr2.namek.link -wss://relay.21spirits.io -wss://relay.minds.io -wss://nostr.d11n.net -wss://nostr.tunnelsats.com -wss://nostr.leximaster.com -wss://mule.platanito.org -wss://nostr.robotechy.com -wss://relay.nostrmoto.xyz -wss://relay.boring.surf -wss://nostr.gruntwerk.org -wss://nostr.hyperlingo.com -wss://nostr.ethtozero.fr -wss://nostr.nodeofsven.com -wss://nostr.jimc.me -wss://nostr.utxo.lol -wss://relay.nyx.ma -wss://nostr.shmueli.org -wss://wizards.wormrobot.org -wss://nostr.sovbit.com -wss://nostr.datamagik.com -wss://relay.nostrid.com -wss://nostr1.starbackr.me -wss://relay.nostr.express -wss://nostr.formigator.eu -wss://nostr.xpersona.net -wss://nostr.digitalreformation.info -wss://nostr-relay.usebitcoin.space -wss://nostr-alpha.gruntwerk.org -wss://nostr-relay.australiaeast.cloudapp.azure.com -wss://nostr-relay.smoove.net -wss://nostr-relay.j3s7m4n.com -wss://nostr.demovement.net -wss://nostr.thesimplekid.com -wss://nostr.aozing.com -wss://nostr.blocs.fr -wss://no.str.watch -wss://btc.klendazu.com -wss://nostr.mrbits.it -wss://nostr.zenon.wtf -wss://no.contry.xyz -wss://nostream.gromeul.eu -wss://relay.nostr.ro -wss://nostr.ncsa.illinois.edu -wss://nostr.itssilvestre.com -wss://nostr.chaker.net -wss://knostr.neutrine.com -wss://nostr.pobblelabs.org -wss://nostr.simatime.com -wss://relay.nosphr.com -wss://student.chadpolytechnic.com -wss://nostr.localhost.re -wss://nostr.coinsamba.com.br -wss://deconomy-netser.ddns.net:2121 -wss://nostr.21m.fr -wss://zur.nostr.sx -wss://nostr-relay.texashedge.xyz -wss://spleenrider.herokuapp.com -wss://nostr.bitcoin.sex -wss://relay.nostrzoo.com -wss://nostr.blockchaincaffe.it -wss://nostr-bg01.ciph.rs -wss://knostr.neutrine.com:8880 -wss://nostr.ahaspharos.de -wss://nostr.argdx.net -wss://nostr.snblago.com -wss://merrcurr.up.railway.app -wss://nostr.bingtech.tk -wss://relay.nostr.wf -wss://relay.koreus.social -wss://nostr.randomdevelopment.biz -wss://relay.nostr.hu -wss://relay.nostr.lu -wss://relay.nostr.ae -wss://middling.myddns.me:8080 -wss://nostr.nikolaj.online -wss://relay.nostrology.org -wss://nostr.satoshi.fun -wss://nostream.kinchie.snowinning.com -wss://nostr.lapalomilla.mx -wss://relay.thes.ai -wss://rsr.uyky.net:30443 -wss://nostrafrica.pcdkd.fyi -wss://nostr.bitcoin-basel.ch -wss://relay.21baiwan.com -wss://nostr.ddns.net:8008 -wss://free-relay.nostrich.land -wss://nostr.lukeacl.com -wss://nostr.ddns.net -wss://nostr.rocket-tech.net -wss://nostr-1.afarazit.eu -wss://nostr.0nyx.eu -wss://nostr-mv.ashiroid.com -wss://lbrygen.xyz -wss://nostr.community.networks.deavmi.assigned.network -wss://nostr.ownscale.org -wss://relay1.gems.xyz -wss://nostr.soscary.net -wss://nostr.0xtr.dev -wss://damus.io -wss://relay.alien.blue -wss://nostr.btcmp.com -wss://relayer.fiatjaf.com -wss://relay.lacosanostr.com -wss://adult.18plus.social -wss://nostrrr.bublina.eu.org -wss://relay.stoner -wss://nostr.pwnshop.cloud -wss://nostr.directory -wss://nostr-relay-dev.wlvs.space -wss://member.cash -wss://relay.nyc1.vinux.app -wss://nostr-relay.digitamob.ro -wss://nor.st -wss://nostr.topeth.info -wss://nostr.rocketstyle.com.au -wss://relay.tnano.duckdns.org -wss://nostr.21l.st -wss://electra.nostr.land -wss://relay.codl.co -wss://nostr.koning-degraaf.nl -wss://relay.mrjohnsson.net -wss://nostr.thank.eu -wss://relay.stonez.me -wss://relay.nostr.distrl.net -wss://relay.valera.co -wss://api.semisol.dev -wss://nostr.lol -wss://relay.shitforce.one -wss://n-word.sharivegas.com -wss://lamp.wtf -wss://nostr.bitcoinpuertori.co -wss://nostr-01.bolt.oberver -wss://3d515c5277e9.ngrok.io -wss://nostr.xmrk.mooo.com -wss://alphapanda.pro -wss://relays.world -wss://universe.nostrich.landlangzh -wss://arnostr.permadao.io -wss://relay.chenxixian.cn -wss://universe.nostrich.landlangzhlangen -wss://v2r.chenxixian.cn -wss://nostr-relay-test.nokotaro.work -wss://universe.nostrich.landlangjalangen -wss://nostr.risa.zone -wss://relay.nosbin.com -wss://translate.argosopentech.com -wss://edennostr.land -wss://nostr.kawagarbo.xyz -wss://nostr.member.cash -wss://ch1.duno.com -wss://nostream-production-b80e.up.railway.app -wss://relay1.nostrich.cloud -wss://relay.t5y.ca -wss://nostr.zhongwen.world -wss://nostr.p2sh.co -wss://nostr.thomascdnns.com -wss://nostream.simon.snowinning.com -wss://relay.nostr.blockhenge.com -wss://nostr.buythisdip.com -wss://nostrua.com -wss://relay.bigred.social -wss://lingoh.dev -wss://nostr.poster.place -wss://nostr.geekgalaxy.com -wss://oarnx6xdrq5mygfdrbmzsvh3is3holefpz2x4qwbopwcicwd63gcivid.onion -wss://relay.nostropolis.xyz -wss://nostream-production-ba43.up.railway.app -wss://nostr.sabross.xyz -wss://relay.nvote.co -wss://nostrati.com -wss://cloudnull.land -wss://nostr.frennet.xyz -wss://nostr.wine.com -wss://nostr.sactiontwo.org -wss://nostr.liberty.fans -wss://nostr.primz.org -wss://btc-italia.online -wss://homenode.local:4848 -wss://nostr.frennet.xyzl -wss://relay.roosoft.com -wss://rasca.asnubes.art -wss://nostr.bitcoin.sexanewlycre -wss://nostr.barf.bz -wss://nostr.middling.mydns.jp -wss://relay.xuzmail.com -wss://no-str.wnhefei.cn:28443 -wss://quirky-bunch-isubghsvoi26fbbt3n7o.wnext.app -wss://nostr.fennel.org:7000 -wss://nostr.0ne.day -wss://nostr.vpn1.codingmerc.com -wss://nostr.jacany.com -wss://nostream.lucas.snowinning.com -wss://relay.beta.fogtype.com -wss://nostr.zue.news -wss://nostream.madbean.snowinning.com -wss://nostr2.rbel.co -wss://relay.1bps.io -wss://nostream-relay-nostr.831.pp.ua -wss://zee-relay.fly.dev -wss://nostrrelay.geforcy.com -wss://relay.nostr.jhot.me -wss://nostr.itredneck.com -wss://nostr.h3y6e.com -wss://relay.bitcoiner.social -wss://hos.lol -wss://iris.to -wss://nostr-pub.senisol.dev -wss://nostr-pub.wellirder.net -wss://bitcoinforthe.lol -wss://relav.nostr.info -wss://3e32-200-229-144-129.ngrok.io -wss://nostr-relay.hzrd149.com -wss://nostr-world.h3z.jp -wss://nostr.thesamecat.io -wss://nostr.compile-error.net -wss://relayable.org -wss://mostra.milou.lol -wss://nproxy.cc -wss://nostr.bitmatk.io -wss://coracle.social -wss://umbrel.local:4848 -wss://nostr.ownbtc.online -wss://wss.nostrgram.co:444 -wss://nostr.minimue81.selfhost.co -wss://relay.current.fy -wss://nostream.nostr.parts -wss://nostr.zebede.cloud -wss://nostrwhoop.ph -wss://relay.nostrified.org -wss://nproxy.zerologin.co -wss://nostr.pcdkd.fyi -wss://relay.kongerik.et -wss://nostr.eden.land -wss://nostr.retroware.run.place -wss://relay.humanumest.social -wss://bhagos.org -wss://hushvault.ie -wss://nostream-production-9458.up.railway.app -wss://nostr.nakamotosatoshi.cf -wss://globals.fans -wss://nostr.cruncher.com -wss://nostr.global.fan -wss://relay.nostr.snblago.com -wss://nostr.nokotaro.com -wss://ostr-1.afarazit.eu -wss://relay.zhix.in -wss://stats.nostr.band -wss://nostr.fine -wss://vxlw4rlg7go34ol43g4gxbvfu4txdzjauquvnbptzwjflezs3vik55id.onion -wss://big.fist.black -wss://universe.nostrich.landlangzhlangja -wss://relay.plebz.space -wss://nostrich.land -wss://relay.mynostr.fun -wss://swiss-enigma.ch -wss://nostr1.current.fyi -wss://relay.atlas.nostr.land -wss://nostr.band -wss://n.wingu.se -wss://nostr.jmdtx.com -wss://nostrproxy-1.f7z.io -wss://nostr.ch -wss://roundrockbitcoiners.com -wss://nostr.sept.ml -wss://srelay.roli.social -wss://nostr.monostr.com -wss://nostr.dojotunnel.online -wss://nostrica.dojotunnel.online -wss://nostr.shadownode.org -wss://thes.ai -wss://rsslay.wss -wss://nostr.vol.io -wss://nostrgram.co -wss://habla.news -wss://runningnostr.lol -wss://mostr.pub -wss://relay.example2.com -wss://profiles.f7z.io -wss://nostr.adpo.co -wss://jp-relay-nostr.invr.chat -wss://nostr.anchel.nl -wss://mutinywallet.com -wss://relay.nostrbr.online -wss://filter.eden.nostr.land -wss://relay.nostrdocs.com -wss://relay.nostr.lucentlabs.co -wss://n.xmr.se -wss://nostr.relayer.rs -wss://monad.jb55.com:8080 -wss://nostr.watch -wss://universe.nostrich.landlangenlangzhlangja -wss://nostr.asdf.mx -wss://ts.relays.world -wss://arc1.arcadelabs.co -wss://stealth.wine -wss://nostr.bg -wss://really.nostr.bg -wss://realy.nostr.bg -wss://relai.nostr.bg -wss://relay-verified.deschooling.us -wss://4.up.railway.app -wss://nostr-relay.aapi.me -wss://nostr-z9tc.onrender.com -wss://nostrich.site -wss://nostr.ginuerzh.xyz -wss://310b-200-229-144-129.ngrok.io -wss://nostr.aste.co -wss://black.nostrcity.club -wss://nostr.guru -wss://nostrica.com -wss://relay.leesalminen.com -wss://nostr.shroomslab.net -wss://meta-relay-beta.nostr.wirednet.jp -wss://nostr.reamde.dev -wss://nostr.africa -wss://powrelay.xyz -wss://rsslay.data.haus -wss://nostr.danvergara.com -wss://nostr.one.re -wss://dev2.hazilitt.fiatjaf.com -wss://nostr-2.zebdeee.cloud -wss://zerosequioso.com -wss://brb.io.relay -wss://relay.realsearch.cc -wss://nodestr.fmt.wiz.biz -wss://r.alphaama.com -wss://nostr.relay-wlvs.space -wss://relay.21spiritis.io -wss://nostr.pinkanki.org -wss://nostr.damus.io -wss://fin-nostr.seekdisruption.com -wss://relay.nostr.lu.ke -wss://nostr.rdfried.com -wss://nostr.trustbtc.org -wss://nostr.verymad.net -wss://relay.damus.info -wss://relays.nostrplebs.com -wss://nostr.688.org -wss://15171031.688.org -wss://dgi4mb7antpcmrx4rynm6xq52xzt5duvxa4iwucq4mszgpz6smrjajqd.onion -wss://mastodon.cloud -wss://nostr.ch3n2k.com -wss://nostr.forecastdao.com -wss://nostr.nostrelay.org -wss://nostr.robotesc.ro -wss://nostr.test.aesyc.io -wss://nostr.web3infra.xyz -wss://nostrrelay.maciejz.net -wss://nostrsxz4lbwe-nostr.functions.fnc.fr-par.scw.cloud -wss://nostrpurple.com -wss://rsslay.ch3n2k.com -wss://nos.qghs.in -wss://nostr.nordlysln.net:3241 -wss://nostr.net.in -wss://relay.rip -wss://universe.nostrich.landlangenlangja -wss://wmv-vm.local:4848 -wss://relay.austritch.net -wss://relay.oxtr.dev -wss://rwlay.bigred.social -wss://relay.lexongtonbitcoin.org -wss://knostr.neutrine -wss://nostr.online -wss://filter.stealth.wine -wss://nostream-production-5895.up.railway.app -wss://nostr.stereosteve.com -wss://relay01.apus.network -wss://test.nostr.0x50.tech -wss://nostr.0x50.tech -wss://nostr.256k1.dev -wss://nostr.malin.onl -wss://jqiwgflfw4dezjsy42frompmknrlcfazoiyngftgknj7yrmnhtobd7id.local -wss://b.ayit.org -wss://nostrelay.rajabi.ca -wss://off20chain.pub -wss://nostr.milou.land -wss://nostr.primedomain.fr -wss://nostr.theblockreward.com -wss://anon.computer -wss://relay.hamnet.io -wss://nostramsterdam.vpx.moe -wss://global-relay.cesc.trade -wss://btcpay.kukks.org -wss://relay.cent2sat.com -wss://nostr.mnethome.de -wss://nostream.sh4.red -wss://klockenga.social -wss://nostream.megadope.snowinning.com -wss://nostr.cvilleblockchain.org -wss://nostr.bitocial.xyz -wss://nostr.bitcoiner.socail -wss://nostr-relay.eniehack.net -wss://greenart7c3.dedyn.io -wss://nostr.data.naus -wss://8.tcp.ngrok.io:19607 -wss://relay.nostr24.com -wss://d463rbo7dgbfuxvvxpory2og2etl4gttfzmqcixdq7rpts47lpgolkyd.onion -wss://dublin.saoirse.dev -wss://www.weixin.com -wss://nostr-rs-relay.cryptoassetssubledger.com -wss://nostr.kojira.net -wss://nostr.fan -wss://nostr.pk -wss://getalpy.com -wss://billert.xyz -wss://circle-ay.info -wss://jawsh.xyz -wss://walletofsatoshi.com -wss://ogblock.xyz -wss://nodestrich.com -wss://kunigaku.gith -wss://nostr.21ideas.org -wss://133332.xyz -wss://asats.io -wss://nostrchack.me -wss://chalow.net -wss://cashu.me -wss://tsukemonogit.git -wss://h3y6e.com -wss://elder.nostr.land -wss://xmr.rocks -wss://nostr.build -wss://mofumemo.com -wss://kpherox.dev -wss://sb.nostr.band -wss://tyiu.xyz -wss://welkinhere.githu -wss://ocha.one -wss://in.tips -wss://vitorpamplona.co -wss://fiatjaf.com -wss://akiomik.github.io -wss://stacker.news -wss://nvk.org -wss://lordkno.ws -wss://nostr.indus -wss://lotdkno.ws -wss://nosutora.com -wss://milou.lol -wss://mostr.pu -wss://dergigi.com -wss://shirehodl.com -wss://cash.app -wss://h3z.jp -wss://murachue.cytes.net -wss://snowcait.gith -wss://jb55.com -wss://nodeless.io -wss://orange-crush.com -wss://nostr.com.au -wss://oooxxx.ml -wss://plebs.place -wss://ahr999.com -wss://penpenpng.github.io -wss://nostrpurple.co -wss://thank.eu -wss://weep.jp -wss://tigerville.no -wss://nostrcheck.me -wss://frenstr.com -wss://ln.tips -wss://ryumu.dev -wss://honeyroad.store -wss://harlembitcoin.com -wss://f7z.io -wss://relay.fan -wss://nisshiee.org -wss://www.lopp.net -wss://getalby.com -wss://heguro.com -wss://wil.bio -wss://b.tc -wss://nodedttich.com -wss://relay.taldra.in -wss://relat.nostrica.com -wss://nostr.boring.surf -wss://nostr.raitisoja.net -wss://nostr.astrox.app -wss://nostr.mjex.me -wss://slick.mjex.me -wss://nostr.hrmb.org -wss://relay.semaphore.life -wss://rss.nostr.band -wss://63ragcfwb5xhoe5gfflazfyrde3qjdo73cblhhmnbviizowdo2q5haid.onion:5051 -wss://nostrblip.app -wss://relay.vanderwarker.family -wss://relay-local.cowdle.gg -wss://relay.damus.com -wss://relay.reeve.cn -wss://relay.strfry.net -wss://relay.alxgsv.com -wss://nostrv0l.io -wss://relay.nostrula.com -wss://release.nostr.band -wss://nostr.k3tan.com -wss://nostr.bitcoin.social -wss://thesimplekid.space -wss://relay.ypcloud.com -wss://at.nostrwork.at -wss://nostream.dev.kronkltd.net -wss://nostr-relay.xbytez.io -wss://relay.nostr.io -wss://relay.nvote.co:443 -wss://relay.cryptoculture.com -wss://rjj6ejkihilniytxs56qrgtttgcfnnjvbii6vaas6jzppcmekd63ugad.local -wss://puravida.nostr.land.com -wss://bitcoinmaximalist.online -wss://wine.nostr -wss://nostr.messagepush.io -wss://nostrich.love -wss://relaynostrplebs.com -wss://hamstr.to -wss://yosupp.app -wss://snort.social -wss://relay.nostr.gt -wss://nost.ratchat.nl -wss://nostr.chrissmith.site -wss://i.relay.boats -wss://nostr.wineto -wss://nostr.eluc.ch -wss://nostrplebs.com -wss://nostr.tools.global.id -wss://nostr.rocketnode.space -wss://relay.roli.social -wss://bitcoin.nostr.com -wss://relay.badgr.space -wss://nostriches.club -wss://nostr-check.me -wss://nostrelay.nokotaro.com -wss://rbr.bio -wss://rly.bopln.com -wss://6amyhf3sjvgxe5qzbx4xn52pcnqresdmi7szxurp6umkvz6mthxjdcad.onion -wss://6amyhf3sjvgxe5qzbx4xn52pcnqresdmi7szxurp6umkvz6mthxjdcad.local -wss://20nos.lol -wss://test.theglobalpersian.com -wss://nostr.exposed -wss://nostr-pub.liujiale.me -wss://nostream.frank.snowinning.com -wss://nstrs.fly.dev -wss://eospark.com -wss://relay.nosterplebs.com -wss://nproxy.kristapsk.lv -wss://nostr.universalname.space -wss://relays.snort.social -wss://nostr.how -wss://kukks.org -wss://nostr.dutch.cryptonews -wss://relay.cryptojournaal.net -wss://relay.dutch.cryptonews -wss://nostr.cryptojournaal.net -wss://no-str.wnhefei.cn -wss://www.131.me -wss://rsr.uyky.net -wss://nostr-relay.ie9.org -wss://nostr.fmt.wis.biz -wss://nostr.exotr.dev -wss://nostr.merrcurr.com -wss://realy.damus.io -wss://nerostr.xmr.rocks -wss://rkdgwzgvcgrciemlnfsxqgyrv5whgpw44s6zycokmuchpq4ucflgjtqd.local -wss://nostr.simplex.icu -wss://ralay.damus.io -wss://relay.kronkitd.net -wss://nostr.info -wss://nostr.lnnodeinsight.com -wss://nostr.truckenbucks.com -wss://brt.io -wss://nostr.lingoh.dev -wss://relay.nor.st -wss://nostr-relay.inmarkets.com -wss://wiz.biz -wss://nostream.git -wss://nostr.dpbu.de -wss://wcl2meyp236fa3dmfzfyq6aacbdoixrlocb6zozjs6xklxizschj2did.local -wss://relay.nostr.co.jp -wss://relap.orzv.workers.dev -wss://nostr.bitcoiner.socia -wss://eden.nosrt.land -wss://strfry.cryptocartel.social -wss://nostr.citizenry.technology -wss://universe.nostrich.landlang -wss://nostr.inprivate.network -wss://relay.snort.test -wss://lpkue6jtz3pnp7zok4jwlct4n3mzuffsfrpagiffsuplyaswhdtmpoid.local -wss://nostr.rezhajulio.id -wss://lnbits.eldamar.icu -wss://nostr.freefrom.fi -wss://yael.at -wss://rain8128.github.io -wss://badges.page -wss://latam1-nostr.stealthy.co -wss://nostr.relay.se -wss://nfdn.testnet.dotalgo.io -wss://relav.nostr3.io -wss://ofchain.pub -wss://nostr.fmt.wiz.bi -wss://relay.gui.dog -wss://eden.nostry.land -wss://nostr.relay.limo -wss://relay.nostrichs.org -wss://20nostr.mom -wss://relay20damus.io -wss://nostr.sandwich.pro -wss://sq.qemura.xyz -wss://nostr-pub.seminol.dev -wss://nostr.eunundzwanzig.space -wss://nostr.bitcoinet.social -wss://nostr-relay.untether.me -wss://relay.nostr.pub -wss://nostr.100p.org -wss://7tdom3xuus7ekv423ul46w3j43zyixjj54yoe62bndpcrgii3adeppid.local -wss://nostr.gram -wss://nostr.videre.net -wss://nostr-2.zebedee.cloudwss -wss://nostr-pub.wellorder.netwss -wss://sonzai.net -wss://snort.fail -wss://ppavybjpqjft5slnpeovehbegomhwtvvxtesvwwdrfndz6qe2c5kf2ad.local -wss://nostr.land -wss://relay.nvote.com -wss://purevida.nostr.land -wss://nostr.bitcoiner.soical -wss://nostr.inosta.co -wss://nas.lol -wss://wss.nostr.milou -wss://nostr.cheesebot.org -wss://eyeswideshut.ath.cx -wss://test.relays.world -wss://relay.snort.com -wss://relay.nostrplebs.co -wss://nostr.kimi.im -wss://nostrum.com -wss://wss.nostr.wine -wss://relay.usenostr.org -wss://paladium.my:4848 -wss://umbrel.home.local:4848 -wss://nostr.metamadeenah.com -wss://lnbits.sdbtc.org -wss://relay.nostr.mutinywallet.com -wss://relay.theglobalpersian.com -wss://relay1.easymeta.app -wss://relay.nostris.online -wss://s1.wonder3.org -wss://eden.nostraland -wss://byc.klendazu.com -wss://ephemerelay.mostr.pub -wss://7si6co27cvaw5yjyx6asvxfmaw5ah2arywwgrem4y5svi5ntskoeb5id.onion -wss://nwmdev.com -wss://nostro.online -wss://nostr.massimux.com -wss://nostr.glate.ch -wss://nostr.openordex.org -wss://nostr.schorsch.fans -wss://nostr-relay-dev.nisshiee.org -wss://ibz.me -wss://alexandernostrplebs.com -wss://fishbanananostrplebs.com -wss://relay.nostrcheck.com -wss://nostr.roli.io -wss://nostr.net.za -wss://nostr.worldkey.io -wss://nostr-pub.welloorder.net -wss://nostr.actin.io -wss://nostrzebedee.cloud -wss://nostr.zclub.app -wss://nostr.13x.sh -wss://nostr.totient.xyz -wss://nostr1.federated.computer -wss://nostr.zhix.in -wss://nostr.vdstruis.com -wss://caro-relay.fiatjaf.com -wss://nostr.winewss -wss://notstro.wine -wss://nostr.btc-library.com -wss://nostr.phenomenon.space -wss://nostr.octr.dev -wss://nostr.impervious.live -wss://nostr.plebs.space -wss://iefan.tech -wss://w3ird.tech -wss://yunginter.net -wss://nostr.coach -wss://sleepy.cafe -wss://freespeechextremist.com -wss://nostr.uselessshit.com -wss://liberdon.com -wss://eveningzoo.club -wss://gleasonator.com -wss://mindly.social -wss://ottawa.place -wss://noagendasocial.com -wss://poa.st -wss://misskey.io -wss://misskey.cf -wss://nostr.bybieyang.com -wss://mastodon.online -wss://toad.social -wss://best-friends.chat -wss://universeodon.com -wss://mastodon.world -wss://mastodon.social -wss://nostr.vulpem -wss://nicecrew.digital -wss://front-end.social -wss://nostr-relay.wellorder.net -wss://relays.pro -wss://rsslay.sovbit.host -wss://seal.cafe -wss://social.6bq.de -wss://universe.nostrich.landlangjalangzh -wss://social.xenofem.me -wss://mi.hibi-tsumo.com -wss://mstdn.social -wss://mas.to -wss://relay.rebelbase.site -wss://pixelfed.social -wss://digitalcourage.social -wss://ruby.social -wss://cr8r.gg -wss://nijimiss.moe -wss://returtle.com -wss://lor.sh -wss://toot.community -wss://mstdn.jp -wss://relay.berserker.town -wss://nostr.rajabi.ca -wss://fosstodon.org -wss://misskey.takehi.to -wss://izj3isbk3pmade74ontdijodhehsytnw2iokdhh6k3flk4mq2pau6sid.onion -wss://mastodon.scot -wss://nostriches.org -wss://aus.social -wss://relay.nostr.amane.moe -wss://romancelandia.club -wss://stonez.me -wss://merrcurr.com -wss://nostr.ist -wss://onprem.wtf -wss://fedibird.com -wss://s2.wonder3.org -wss://freespech.casa -wss://disabled.social -wss://relay.house -wss://nostr-pub.welllorder.net -wss://social.teamb.space -wss://infosec.exchange -wss://blockedur.mom -wss://progressivecafe.social -wss://mstdn.ca -wss://c.im -wss://mefi.social -wss://basebitcoinplebs.place -wss://med-mastodon.com -wss://ohai.social -wss://defcon.social -wss://pxlmo.com -wss://zlocur7ctbds4qsdswb3qpkp6n2e2ywqne2fp2tdn4fqubpudfiyxwid.local -wss://thebag.social -wss://mstdn.party -wss://relay.serpae.xyz -wss://clew.lol -wss://qoto.org -wss://relay.exchange -wss://nostr.relayable.org -wss://mastodon.coffee -wss://kmy.blue -wss://home.social -wss://detmi.social -wss://brighteon.social -wss://nostr.nom -wss://theblower.au -wss://astral.nostr.land -wss://beige.party -wss://orangepill.dev -wss://redgreenblue.click -wss://nostr.fredix.xyz -wss://nostr.essydns.ca -wss://nostr.private.network -wss://nostr.member.cas -wss://nostr-rely.digitalmob.ro -wss://relay.nostr -wss://relay.current -wss://no-str.or -wss://pl.gamers.exposed -wss://studentchadpolytechnic.com -wss://scicomm.xyz -wss://relay.alien-sos.gov -wss://masto.es -wss://spinster.xyz -wss://parallels-parallels-virtual-platform.local:4848 -wss://relay.daums.io -wss://kolektiva.social -wss://mastodonapp.uk -wss://convo.casa -wss://sfba.social -wss://techhub.social -wss://leafposter.club -wss://nrw.social -wss://mastodon.uno -wss://handon.club -wss://social.vivaldi.net -wss://nostr.goller.net -wss://minazukey.uk -wss://mstdn.beer -wss://expressional.social -wss://paid.nostr.0x50.tech -wss://mstdn.nere9.help -wss://relay.nostr.ai -wss://relay.noswss -wss://relay.nwss -wss://furry.engineer -wss://uselessshit.co -wss://kosmos.social -wss://mynostr.io -wss://premis.one -wss://social.tchncs.de -wss://retro.pizza -wss://pearl-mount-showed-fishing.trycloudflare.com -wss://kpa4k6acxzjv2m2p72keftbpaymwpq2h67jqnin3d4y3djxyheuifoqd.onion -wss://gm7.social -wss://relays.nostr.info -wss://chitter.xyz -wss://hackers.town -wss://anarchism.space -wss://relay.nostrica -wss://halifaxsocial.ca -wss://asimon.org -wss://nostr.blipme.add -wss://troet.cafe -wss://octodon.social -wss://m.cmx.im -wss://filename-ambassador-distance-mountains.trycloudflare.com -wss://nostr.getgle.org -wss://relay.froth.zone -wss://novoa.nagoya -wss://relay.dispute.systems -wss://clubcyberia.co -wss://thechimp.zone -wss://coolsite.win -wss://puravida.nostra.land -wss://shelter.local:1111 -wss://det.social -wss://nostr-2.zebee.cloud -wss://chaosfem.tw -wss://nostr.v01.io -wss://social.kechpaja.com -wss://mastodon.green -wss://plebchain.nostr -wss://plebchain.nostr.land -wss://relay.onsats.org -wss://u5epuanp2fbie4phw6zekzna6zotvsffji4td4ee7iwgwdxlz4kwqqad.onion:5051 -wss://3ddc8bbee6db.ngrok.app -wss://b4968f09859e.ngrok.io -wss://genserver.social -wss://masto.ai -wss://hachyderm.io -wss://shitpost.cloud -wss://oldbytes.space -wss://mastodon.ie -wss://baraag.net -wss://nostr.dvdt.dev -wss://relay.com.de -wss://nostr.rbel.co -wss://bologna.one -wss://nostr-relay.app -wss://kagamisskey.com -wss://nostr-rs-relay.phamthanh.me -wss://nostr.bcmp.com -wss://blogstack.io -wss://rly.nostrkid.com -wss://mastodon.bida.im -wss://freeatlantis.com -wss://pieville.net -wss://climatejustice.rocks -wss://relay.mynostr.id -wss://relay.farscapian -wss://relay.blogstack.io -wss://orwell.fun -wss://misskey.04.si -wss://sushi.ski -wss://nostr.milou.lo -wss://pawoo.net -wss://tooter.social -wss://mastodon.sdf.org -wss://nostr.com -wss://misskey.design -wss://macaw.social -wss://relay.nostr.inforelay.nostr.band -wss://pub1.southflorida.ninja -wss://strawberry-pudding.net -wss://mastodon-japan.net -wss://nostream.0x50.tech -wss://multiplextr.coracle.social -wss://pleroma.skyshanty.xyz -wss://uxxq6b2enojvflhkrzsg4erakd5rrb7v2cql4m4pspj4xtqwyli47rid.local -wss://masto.deoan.org -wss://replay.damus.io -wss://melhorque.com.br -wss://nostr1.actn.io -wss://nostr.bolt.fun -wss://relay.vtbmoyu.com -wss://eosla.comrelay.zeh.app -wss://bikeshed.party -wss://nostr.xanny.family -wss://milker.cafe -wss://nostr.give.africa -wss://relay.got-relayed.com -wss://cum.salon -wss://puravid.nostr.land -wss://soc.punktrash.club -wss://seafoam.space -wss://h4.io -wss://nostr.swiss.enigma.ch -wss://toot.cafe -wss://sneed.social -wss://newsie.social -wss://indg.club -wss://xoxo.zone -wss://relay.nostr.bitcoiner.social -wss://relay.snort.band -wss://socel.net -wss://social.coop -wss://postpandemicparty.org -wss://merveilles.town -wss://search.nostr.wine -wss://search.nos.today -wss://mstdn-huahin.com -wss://eden.nostr.la -wss://nostrja-kari-nip50.heguro.com -wss://relay.gems.xyz -wss://plnetwork.xyz -wss://nostr.swiss-enigma.sh -wss://geofront.rocks -wss://toot.io -wss://indieweb.social -wss://mastodon.content.town -wss://awaymessage.club -wss://relay.intify.io -wss://mstdn.science -wss://maniakey.com -wss://otadon.com -wss://mstdn.guru -wss://misskey.noellabo.jp -wss://hessen.social -wss://mastodon.top -wss://ipv6.nostr.wirednet.jp -wss://relay.nostr-relay.org -wss://cum.camp -wss://nebbia.fail -wss://current.fyi -wss://bae.st -wss://lolison.network -wss://ioc.exchange -wss://bylines.social -wss://decayable.ink -wss://nostr.unitedserializer.com -wss://urbanists.social -wss://dark-elves.social -wss://writing.exchange -wss://nostr.rikmeijer.nl -wss://misskey.social -wss://ht.nixre.net -wss://mathstodon.xyz -wss://t7jvqwu35hneszx7fihsprbcpwonlcfnsjr4xtn6shqgwbv324w4gdid.local -wss://abla.news -wss://muenchen.social -wss://homeserver.drake-carp.ts.net:4848 -wss://snailedit.social -wss://mastodon.gamedev.place -wss://tech.lgbt -wss://mast.lat -wss://econtwitter.net -wss://veganism.social -wss://btclolap6mm4tl37huslk6j76enq7qxaj2kwq7w6cdr5ros56eletcqd.onion -wss://toot.cat -wss://nostr.zenon.info -wss://misskey.art -wss://nostr.hushvault.ie:4848 -wss://o6ga6lxnax2z7pgkkenifollohkrv55r36mdzdtofi7d5yyif2f4o5yd.onion:5051 -wss://20nostr-pub.wellorder.net -wss://ecoevo.social -wss://nostr.zerofiat.world -wss://nostr.atitlan.io -wss://detroitriotcity.com -wss://rollenspiel.social -wss://paquita.masto.host -wss://literatur.social -wss://dave.st.germa.in -wss://sunny.garden -wss://nostrpro.xyz -wss://relay.getalby.com -wss://misskey.systems -wss://mamot.fr -wss://social.anoxinon.de -wss://relay.nostr.social -wss://mindmachine.org -wss://microblog.club -wss://relay.utxo.com -wss://universe.nostrich.landlangth -wss://social.teci.world -wss://social.freetalklive.com -wss://mk.absturztau.be -wss://social.ornella.xyz -wss://left-tusk.com -wss://bofh.social -wss://a11y.social -wss://shroomslab.net -wss://relay2.nostr.vet -wss://mugicha.club -wss://laserbeak.local:4848 -wss://annihilation.social -wss://humble.cafe -wss://nostr.relay.damus.io -wss://nostr.milol.lol -wss://nostr.blimpme.app -wss://noc.social -wss://wetdry.world -wss://nostr.taxi -wss://nostr.21-bitcoin.org -wss://relay.llevotu-bitcoiners.info -wss://pl.kitsunemimi.club -wss://djsumdog.com -wss://citadel.local:4848 -wss://federate.blogpocket.com -wss://chaos.social -wss://mastodon.me.uk -wss://oxtr.dev -wss://misskey.cloud -wss://varishangout.net -wss://lacosanostr.com -wss://willem.currycash.net:4848 -wss://lightniningrelay.com -wss://queer.party -wss://lightning.relay.com -wss://mastodon.nl -wss://purplepag.es -wss://cupoftea.social -wss://bitcoiner.socialwss -wss://relay.current.io -wss://sigmoid.social -wss://wandering.shop -wss://braydmedia.de -wss://quey.la -wss://nostr.zebeedee.cloud -wss://nostr-2.zebeedee.cloud -wss://artsio.com -wss://nostr1676031941328.app.runonflux.io -wss://arsip.ddns.net -wss://relay.nostrcitadel.org -wss://relay.nostr-citadel.org -wss://nostr-citadel.org -wss://blastr.f7z.io -wss://nostr.myowndamnnode.com -wss://snowdin.town -wss://nostr.zxcvbn.space -wss://relay.notmandatory.org -wss://bitcoin.social -wss://friendsofdesoto.social -wss://zirk.us -wss://digipres.club -wss://nostr-test.elastos.io -wss://nostr.onsat.org -wss://damus.relay.io -wss://beefyboys.win -wss://nostrija-kari.heguro.com -wss://froth.zone -wss://ns.penseer.com -wss://relay.nostrical.com -wss://nostr.hoshizora.ch -wss://kunigaku.github.io -wss://nostr.shino3.net -wss://umbrel.tailbb128.ts.net:4848 -wss://tailbb128.ts.net:4848 -wss://fedi.twoshortplanks.com -wss://filter.nostr.winebroadcasttrue -wss://nostr.doufu-tech.com -wss://relay.hodl.haus -wss://pgh.social -wss://coeditor-congested.fractalnetworks.co -wss://mastodon.podaboutli.st -wss://frenfiverse.net -wss://nostr.f4255529.fun -wss://nostream.megadope.snowinning -wss://kiritan.work -wss://forever21.lol -wss://zitron.net -wss://mastodon-swiss.org -wss://relay1.current.fyi -wss://nostr.plebs.com -wss://multiplextr.corocal.social -wss://nostr.zedebee.cloud -wss://nostr.mutinywallet.comisntbanned.addthatonesotheycangetnotesrelayedtotherestofthenetworkfrominsideofchina -wss://mastodon.mit.edu -wss://relaynostrati.com -wss://relaynostr.band -wss://relaynostr.info -wss://relaychenxixian.cn -wss://relaysnort.social -wss://relaynostr.com.au -wss://nostr-tbd.website -wss://relay.hoshizora.ch -wss://social.balsillie.net -wss://strangeobject.space -wss://relay.nvote20.co -wss://fla.red -wss://urusai.social -wss://chad.polytechnic.com -wss://robo358.com -wss://conxole.io -wss://relay.ohbe.me -wss://nostr.bitcoiner.com -wss://www.mutinywallet.com -wss://x.9600.link:10070 -wss://a11y.info -wss://homelab.host -wss://k65qz57zx4sw24fow2bvchjgmpjqljj4cp3oa7dtpbbprjifsdotggid.local -wss://relay.vtuber.directory -wss://x.9600.link:8000 -wss://nostr.plebs.win -wss://renkontu.com -wss://nost.massmux.com -wss://submarin.online -wss://social.librem.one -wss://relai.kongerik.et -wss://mstdn.io -wss://astral.swiss-enigma.ch -wss://stereophonic.space -wss://relayer.pleb.social -wss://mastodon.nz -wss://nostr.nostr.de -wss://nostr.thezap.club -wss://proxy.shroomslab.net -wss://pfr24mrpxowclhm4y6adu36kbo3erx7gskzyfqqhnhfpdkdmylpfgkyd.local -wss://nostr.hodl.haus -wss://vtdon.com -wss://relay.nost.band -wss://fnxwipsg3lfzij64lvjgmutvkkpd7eo2mr2khxkofyywf3vsvbk73jad.onion -wss://fnxwipsg3lfzij64lvjgmutvkkpd7eo2mr2khxkofyywf3vsvbk73jad.local -wss://md.hugo.nostr -wss://boks.moe -wss://truthsocial.co.in -wss://mastodon.xyz -wss://relay.universalname.space -wss://relay-nostr.wirednet.jp -wss://fedi.pawlicker.com -wss://favcalc.com -wss://rot13maxi.com -wss://awayuki.net -wss://mazinkhoury.com -wss://g0v.social -wss://shpposter.club -wss://a.lufimianet.jp -wss://pura20vida.nostr.land -wss://sotalive.net -wss://sendsats.lol -wss://vida.page -wss://wagvwfrdrikrqzp7h3b5lwl6btyuttu7mqpeji35ljzq36ovzgjhsfqd.onion -wss://uec2cmjauzufrtlq6wq6l2ujfncdvo3suezz423gsvz5xvhehm2mcgid.onion -wss://mastodon-belgium.be -wss://rejecttheframe.xyz -wss://nogood.store -wss://getaiby.com -wss://plebchain.club -wss://nostrverified.com -wss://x.9600.link -wss://n0p0.shroomslab.net -wss://orangemakura.xyz -wss://jamw.net -wss://7ab7qqbj2dw3pjnkoskgsfn4ikqc7orwnkpmcfjmeobw63kf4zgykjid.local -wss://snabelen.no -wss://floss.social -wss://mastoot.fr -wss://kmc-nostr.amiunderwater.com -wss://hodl.camp -wss://xmr.usenostr.com -wss://nostr-pub.wellorn.net -wss://gudako.net -wss://ryona.agency -wss://kafeneio.social -wss://massmux.com -wss://freezepeach.online -wss://nostream-production-f83d.up.railway.app -wss://taobox.pub -wss://mastodon.radio -wss://einundzwanzig.relay.com -wss://journa.host -wss://social.here.blue -wss://toot.wales -wss://staging.nostr.com.se -wss://pleroma.soykaf.com -wss://berserker.town -wss://zapforart.site -wss://forall.social -wss://nostrja-kari.heguro.comyee -wss://higheredweb.social -wss://social.gnuhacker.org -wss://the.hodl.haus -wss://ng4jk6yiqgfczo4wyxszuj7w6jok3fptehu533o3mlzs3vph3dvjfdid.onion -wss://nostr.mtpx.ovh -wss://relay.coollamer.com -wss://glasgow.social -wss://fedi.absturztau.be -wss://nostr.frostr.xyz -wss://relay.runningnostr.lol -wss://relay2.vtuber.directory -wss://nerdculture.de -wss://nostr-relay.untethr.meoperator -wss://nostr.verif-slothy.win -wss://nostr.pub -wss://social.process-one.net -wss://invillage-outvillage.com -wss://otofu.uk -wss://universe.nostrich.landlangenlangpt -wss://mastodon.iriseden.eu -wss://frighteningdeafeningagent.nailuogg.repl.co -wss://obo.sh -wss://relay.hodlhaus.net -wss://rdrama.cc -wss://nostr.packetlostandfound.us -wss://test.relay -wss://mastodonbooks.net -wss://southflorida.ninja -wss://blob.cat -wss://tooting.ch -wss://relay.pineapple.pizza -wss://relay.nostr.directory -wss://relar.nostr.bg -wss://reisen.church -wss://relay.honk.pub -wss://nostr.rsfriedl.com -wss://relay.nort.social -wss://1611.social -wss://the.hodl.house -wss://relayable.com -wss://fedi.syspxl.xyz -wss://nostrpub.yeghro.site -wss://skr5bbrgzfnideglw4cs2iw6au2jm2b7gupocxbmkv5qopo6rcitmiqd.local -wss://a2mi.social -wss://status.relayable.org -wss://toot.blue -wss://btcqspp5dl4rlgl5pomcyv3odfeki7a5zrmjoekyu5vsoqz5bth4e7yd.onion -wss://bitcoinr6de5lkvx4tpwdmzrdfdpla5sya2afwpcabjup2xpi5dulbad.onion -wss://7tdom3xuus7ekv423ul46w3j43zyixjj54yoe62bndpcrgii3adeppid.onion -wss://dnze4ekho2kuiejwatjw5omeprtmdaum2ukok52roiu5rztii3rp2aid.onion -wss://53snncs7vegargpaardbxjnii2oan3xpmbeaf6czwoqa2axz5mvbsjid.onion -wss://fnqdhz3df33da6wxg7jskvumd5rjn3nknln6ecun7uwwysc7vkwkjgid.onion -wss://relay.thefockinfury.wtf -wss://silliness.observer -wss://freak.university -wss://piaille.fr -wss://nostr.weking.tk -wss://f.reun.de -wss://radixrat.com -wss://hostux.social -wss://chat.freenode.net -wss://no.str..cr -wss://nostr.inoata.cc -wss://kappa.seijin.jp -wss://floyds.io -wss://mast.dragon-fly.club -wss://zbd.ai -wss://misc.name -wss://pdx.social -wss://0w0.is -wss://d6jvu2tev2rblkuzgu4ydw2413jizr53j26ut47hxpykvtusbvekhiid.onion -wss://d6jvu2tcv2rblkuzgu4ydw24l3jizr53j26ut47hxpykvtusbvekhiid.onion -wss://drcassone.social -wss://mastodon.energy -wss://rayci.st -wss://ischool.social -wss://nostr.halfway2forever.com -wss://relay.nostr.rocks -wss://nostr.stoner.com -wss://2nodez.com -wss://rs.nostr-x.com -wss://schleuss.online -wss://thrashzone.org -wss://relay.nostrgraph.com -wss://relay.2nodez.com -wss://occult-zuki.com -wss://mastodon.lithium03.info -wss://bigbadpc.local:4848 -wss://social.gr0k.net -wss://nostr.wirednet.jp -wss://relay.plebster.com -wss://bitcoin.nostr -wss://mastodon.im -wss://brb..io -wss://nostr.sandwhich.farm -wss://relay.nos.lol -wss://beta.nostr.v0l.io -wss://eden.nostr.space -wss://powerlay.xyz -wss://rap.social -wss://relay.mutinywallet.com -wss://rogue.earth -wss://600.wtf -wss://klabo.blog -wss://petrikajander.com -wss://tgkzmdd.help -wss://nostr.red -wss://brb.lol -wss://jz2l2bf6f6wssdqwkg7ogthkc5i3ymyiwkaz3tbhff6ro3h3zqddekyd.onion -wss://mstdn.mini4wd-engineer.com -wss://nostr.exposd -wss://nostr.a-ef.org -wss://computerfairi.es -wss://social.targaryen.house -wss://o3o.ca -wss://walkah.social -wss://cosocial.ca -wss://filter.nostr.band -wss://relais.nostrview.com -wss://gigaohm.bio -wss://dobbs.town -wss://bark.lgbt -wss://mastodon.gal -wss://snug.moe -wss://genomic.social -wss://relay.orangepilldev.com -wss://social.sdf.org -wss://social.camph.net -wss://mstdn.poyo.me -wss://nein.lol -wss://nostr.i00.org -wss://kemono.ink -wss://mu.zaitcev.nu -wss://libera.site -wss://ca.hibi-tsumo.com -wss://social.bund.de -wss://xscape.top -wss://social.lol -wss://birds.town -wss://arnostr.com -wss://nostr.33co.de -wss://relay.nostr.lighting -wss://metadata-contacts-relays.pages.dev -wss://webs.node9.org -wss://pleroma.elementality.org -wss://suya.place -wss://livellosegreto.it -wss://peoplemaking.games -wss://nattois.life -wss://typo.social -wss://neutrine.com -wss://ragner-relay.com -wss://wss.nostr.uselessshit.co -wss://wss.nostrue.com -wss://relay.nvote.co:433 -wss://disobey.net -wss://rneetup.com -wss://arnostr.com:8433 -wss://relay.uxto.one -wss://relay.hackerman.pro -wss://thisis.mylegendary.quest -wss://poliversity.it -wss://sats.lnaddy.com -wss://rs2.abaiba.top -wss://rs1.abaiba.top -wss://rs2.abaiba.top.abaiba.top -wss://social.matarillo.com -wss://nostr01.counterclockwise.io -wss://backup.local:4848 -wss://touhou.vodka -wss://mi-wo.site -wss://nostr.f7z.io -wss://alive.bar -wss://strfry.nostr-x.com -wss://mastodontti.fi -wss://nostr.wellorder.net -wss://y.9600.link:8000 -wss://ephemrelay.mostr.pub -wss://byc-italia.online -wss://fissionator.com -wss://stranger.social -wss://eupolicy.social -wss://nostr-desktop.local:4848 -wss://aoir.social -wss://mstdn.plus -wss://nostrproxy.io:3333 -wss://mastodon.hams.social -wss://jorts.horse -wss://metalhead.club -wss://dice.camp -wss://mstdn.y-zu.org -wss://loffchain.pub -wss://mastodon.llarian.net -wss://nostr-relay2.thefockinfury.wtf -wss://2g2jzcfgq5lcrceuq23lmya2drm3ku5qmqimr3bvu3amol55vidctrad.onion -wss://mastodon.au -wss://bgme.me -wss://nostr.badran.xyz -wss://nostr.coincreek.com -wss://nostream-test.up.railway.app -wss://relay.blackthunder.click -wss://relay.grorp.com -wss://atomicpoet.org -wss://iddqd.social -wss://gusto.masto.host -wss://lifehack.social -wss://blorbo.social -wss://freecumextremist.com -wss://13bells.com -wss://rsslay.nostr.netrelay -wss://relay.zerosequioso.com -wss://nauka-relay.herokuapp.com -wss://nostr.nofdeofsven.com -wss://out.of.milk -wss://cawfee.club -wss://r.relay.fan -wss://alo.ottonove891.cf -wss://keinoha.tailnet-0240.ts.net -wss://nostr.paralelnipolis.cz -wss://tuiter.rocks -wss://elizur.me -wss://nostr-dev.newstr.io -wss://discuss.systems -wss://blahaj.zone -wss://mastodon.art -wss://makersocial.online -wss://gamepad.club -wss://nostr.flameofsoul.ru -wss://dmv.community -wss://relay.nostr.com -wss://nostr-desktop.saiga-shark.ts.net:4848 -wss://nostrfoxden.ddns.net:4848 -wss://soc.umrath.net -wss://ravenation.club -wss://oisaur.com -wss://nostr-relay.net -wss://social.mikutter.hachune.net -wss://lewacki.space -wss://fediscience.org -wss://todon.eu -wss://nuccy-nuc7i5bnk.local:4848 -wss://games.gamertron.net:4848 -wss://fiedlerfamily.net -wss://postnstuffds.lol -wss://nostr.planetary.social -wss://worldkey.io -wss://hcommons.social -wss://gymp7qquljs47xbbvs47hkptnyyzegy2jkst26mkjxaciifqffjatqid.onion -wss://rs3.abaiba.top -wss://sudo-nostr.com -wss://satgag.site -wss://nostr.lnbitcoin.cz -wss://relay20nostrplebs.com -wss://2pbkpndvpeebljfvjew6auq63lndzszqnntct5aqfmazslerzxe75kad.onion -wss://dragonchat.org -wss://welcome.nostr.wine -wss://relay.nostr.land -wss://social.linux.pizza -wss://dnppj4kopczovvzvpzmihv2iwe5wt3gbrxjnltjc2zdjpttrdz4owpad.onion -wss://potofu.me -wss://nostrbr.online -wss://mastorol.es -wss://notebook.taild34d0.ts.net -wss://t.aqn.jp -wss://nostr.mutinywallet -wss://wcone.nostr.wine -wss://norden.social -wss://eostagram.com -wss://shigusegubu.club -wss://toot.jkiviluoto.fi -wss://kiwifarms.cc -wss://swiss-talk.net -wss://v532btfg2fb4za2g476a7w23pgpkllc7uq274wqtktwjogt5ynb3ukqd.local -wss://mstdn.maud.io -wss://arc1.arcadelabs.com -wss://nostr.jp -wss://relay-jp.nostr.wirrdnet.jp -wss://climatejustice.social -wss://witter.cz -wss://mastodon.pnpde.social -wss://ttrpg-hangout.social -wss://beehaw.org -wss://thecanadian.social -wss://nostr.fbxl.net -wss://relay.sandwich.farm -wss://nostr.olwe.link -wss://botsin.space -wss://zeroes.ca -wss://photog.social -wss://paid.nostr.lc -wss://free.nostr.lc -wss://test.nostr.lc -wss://gzanlkgurj7zd3psqms3da4vrw4imurnyyzaycfuiiug7elqow7xlayd.onion:5051 -wss://masto.nu -wss://mastodon.uy -wss://bit.relay.center -wss://offchain.relay.center -wss://damus.relay.center -wss://wine.relay.center -wss://eden.relay.center -wss://moth.social -wss://nostr.masmux.com -wss://chrome.pl -wss://mastodon.ktachibana.party -wss://ak.kawen.space -wss://mementomori.social -wss://relay.s3x.social -wss://lnbits.michaelantonfischer.com -wss://yof23ggqmert72c5wcl5qglphapy3o2xjdedtkbrn2dt5rbae2s7f6qd.onion -wss://relay.snort.socail -wss://post.lurk.org -wss://yiff.life -wss://q3zaylwjjhq77yzx34lbydz26szzjljberwetkjgxgsapcekrpjzsmqd.onion -wss://lnbits.b1tco1n.org -wss://welcome.nostr.relay -wss://sound-money-relay.denizenid.com -wss://carnivore-diet-relay.denizenid.com -wss://africa.nostr.joburg -wss://nostr.jolt.run -wss://nostr.chainbits.co.uk -wss://ithurtswhenip.ee -wss://nostr.cloudversia.com -wss://relay1.east.us.nostr.btron.io -wss://ca.orangepill.dev -wss://pdx.land -wss://linh.social -wss://okla.social -wss://androiddev.social -wss://spore.social -wss://mastodo.fi -wss://kabedon.space -wss://nost.inosta.cc -wss://relay2cdamus.io -wss://nostr.openhoofd.nl -wss://dragonscave.space -wss://genart.social -wss://dewp.space -wss://layer8.space -wss://qou7zzll2mxx2ehl73n6pptmhizl5b3entowljlin3sqhcvltxdtlmad.onion:5051 -wss://nostr.wines -wss://relay.snort.relay.ryzizub.com -wss://pixelfed.de -wss://nostr.holyscapegoat.com -wss://nostr.einunzwanzig.space -wss://nostr.hifish.org -wss://colearn.social -wss://topspicy.social -wss://mastodon.neat.computer -wss://relay.nostr.hach.re -wss://nostr.dakukitsune.ca -wss://7ab7qqbj2dw3pjnkoskgsfn4ikqc7orwnkpmcfjmeobw63kf4zgykjid.onion -wss://esq.social -wss://famichiki.jp -wss://tribe.net -wss://masto.nobigtech.es -wss://umbrel-nuc.local:4848 -wss://mastodon.cocoasamurai.social -wss://debian.taildd32b.ts.net:4848 -wss://vlt.ge -wss://relay.johnnyasantos.com -wss://snort.relay.center -wss://nb.relay.center -wss://waag.social -wss://concentrical.com -wss://stat.rocks -wss://oransns.com -wss://relay-jpp.nostr.wirednet.jp -wss://oc.todon.fr -wss://jundow.gitlab.io -wss://neurodifferent.me -wss://jazztodon.com -wss://nostrich.friendship -wss://indieauthors.social -wss://werunbtc.com -wss://frogtalk.lol -wss://pop-os.local:4848 -wss://fediver.de -wss://d6qxo55dhms6revgrmbindvb5ejd3gw5hrji7ylkm6khghii3hjs3uyd.onion -wss://eldritch.cafe -wss://karlsruhe-social.de -wss://social.yl.ms -wss://nostr.mycloudhouse.duckdns.org -wss://nostr.otc.sh -wss://nya.social -wss://relay2.nostrchat.io -wss://relay1.nostrchat.io -wss://nostrja-world-relays-test.heguro.com -wss://ndk-relay.local -wss://reespeech.casa -wss://pleroma.atyh.cc -wss://lawfedi.blue -wss://akkoma.jasminetea.uk -wss://peeledoffmy.skin -wss://plush.city -wss://astrodon.social -wss://samenet.social -wss://toot.bike -wss://mi.yukioke.com -wss://social.growyourown.services -wss://mastodon.nu -wss://lou.lt -wss://functional.cafe -wss://relaydamus.io -wss://coma.social -wss://social.fbxl.net -wss://biplus.social -wss://toots.matapacos.dog -wss://psychoet.ml:3250 -wss://danserver.equipment -wss://nostr.lacrypta.com.ar -wss://wonkodon.com -wss://nostr.seankibler.com -wss://autistics.life -wss://cambrian.social -wss://rvqkqr5kl3dvvxyn67rfowcnvoflx4zby5tjbysavym4ycckti4dbjyd.onion -wss://swiss.nostr.lc -wss://snac.saifulh.online -wss://loma.ml -wss://nostr.privoxy.io -wss://366.koyomi.online -wss://mastodon.stormy178.com -wss://podcastindex.social -wss://bitcoiner.socia -wss://fedi.ml -wss://replayable.org -wss://nostr.schroomslab.net -wss://nostr.global.fans -wss://relay.weedstr.net -wss://vocalodon.net -wss://relay.nostr.bandadd -wss://nostr.oxtr.devadd -wss://jarvis.taild68e2.ts.net:4848 -wss://t7jvqwu35hneszx7fihsprbcpwonlcfnsjr4xtn6shqgwbv324w4gdid.onion -wss://stonez.local:4848 -wss://notrustverify.ch -wss://woof.group -wss://mastodon.floe.earth -wss://lnbits.plebtag.com -wss://kpop.social -wss://relay.wavlake.com -wss://mastodon.sharma.io -wss://travelpandas.fr -wss://alphapanda.prowss -wss://relay.saes.io -wss://barelysocial.org -wss://masto.komintern.work -wss://norcal.social -wss://nostr.zbd.gg -wss://mk.outv.im -wss://mstdn.mx -wss://col.social -wss://nostr.freedom.fi -wss://filter.nostr.winebroadcasttrueglobalall -wss://eden.nostr.landv -wss://me.dm -wss://emacs.ch -wss://winonostr.wine -wss://infoplebstr.com -wss://mas.towss -wss://gruene.social -wss://relay.freeplace.nl -wss://itis.to -wss://bsky.social -wss://lnbits.thefockinfury.wtf -wss://5xxkt7zvmh4zdsjw64lgvchjdlrrgw4w2huujiiud35qms6gnkn5azad.onion -wss://eientei.org -wss://artisan.chat -wss://nustr.mom -wss://relay.nostrhraph.net -wss://shitposter.club -wss://nostril.cam -wss://nostr.spaceshell.xyz -wss://relay.wtr.app -wss://tdd.social -wss://d3meec25b53kegrnjmtmtyynikbkmuxf4jqgtk3sonjs6e62hpaezyqd.onion -wss://tkz.one -wss://freerelay.xyz -wss://nfdn.betanet.dotalgo.io -wss://mitra.social -wss://hablanews.io -wss://calle.wtf -wss://voskey.icalo.net -wss://nostr.sloyhy.win -wss://framapiaf.org -wss://nostrnodeofsven.com -wss://ciberlandia.pt -wss://gnusocial.net -wss://relay.s3x.socia -wss://paste.2nodez.com -wss://union.place -wss://bofh.socia -wss://sovbit.dev -wss://lnbits.btc-payserver.eu -wss://osna.social -wss://im-in.space -wss://junxingwang.org -wss://relay.nostr.wirednet.jpcheck -wss://libranet.de -wss://fedisnap.com -wss://woodpecker.social -wss://gensokyo.town -wss://social.imirhil.fr -wss://links.potsda.mn -wss://law-and-politics.online -wss://nostrich.bar -wss://tweesecake.social -wss://nostr.kisiel.net.pl -wss://relay.kisiel.net.pl -wss://calckey.social -wss://nostr.cercatrowa.me -wss://meganekeesu.tokyo -wss://lndiscs.duckdns.org -wss://omochi.xyz -wss://wue.social -wss://nostr.libreleaf.com -wss://rusnak.io -wss://rsslay-production.up.railway.app -wss://nostr.yuhr.org -wss://bod4ojj37fneith2setv3qjbii563wesbjqgdipdz4ag6voic2xk5iad.onion -wss://fediverse.blog -wss://pouet.chapril.org -wss://baq5ufl2rnczpalnoqabxwpjm3kvhzduwgvptxzx7yq37oqdbgf65syd.local -wss://baq5ufl2rnczpalnoqabxwpjm3kvhzduwgvptxzx7yq37oqdbgf65syd.onion -wss://cryptodon.lol -wss://nostr.debancariser.com -wss://mizunashi.hostdon.ne.jp -wss://relay.deezy.io -wss://bbq.snoot.com -wss://historians.social -wss://mi.mashiro.site -wss://mastodonpost.social -wss://nostrpub.welliorder.net -wss://social.cologne -wss://metapixl.com -wss://wandzeitung.xyz -wss://lightninhrelay.com -wss://techopolis.social -wss://lnbits.btcpins.com -wss://purplenostrich.com -wss://onewilshire.la -wss://sself.co -wss://anygemini13.blogs.sapo.pt -wss://federated.press -wss://metadata.nostr.com -wss://nostr.hodl.ar -wss://goreslut.xyz -wss://mastodon.com.tr -wss://climatejustice.global -wss://brotka.st -wss://sueden.social -wss://mstdn.fr -wss://abid.cc -wss://lnbits.fuckedbitcoin.com -wss://meow.social -wss://nostr.rehab -wss://mstdn.media -wss://nodeo1.nostress.cc -wss://nostrmassmux.com -wss://sauropods.win -wss://civilians.social -wss://pnw.zone -wss://zebeedee.cloud -wss://nostr.walletofsatishi.com -wss://aufovmqaxj5nhqmtorhgpogdjxefhkff25cbyyjt2sub3vwg6b6rplid.onion -wss://forfuture.social -wss://76f67qcwxsxpz7cfozlzunota2ejqznpldc5pnqtyq233hjpjzrmlfid.local -wss://toot.garden -wss://umbrel.tail9dfb.ts.net:4848 -wss://mastodon.chasem.dev -wss://misskey.pm -wss://merovingian.club -wss://chirp.enworld.org -wss://paid.no.str.ce -wss://masto.bike -wss://masto.1146.nohost.me -wss://eosla.comno-str.orgrelay.zeh.appno-str.orgrelay.zeh.app -wss://nixnet.social -wss://pay.zapit.live -wss://respublicae.eu -wss://nekomiya.net -wss://mastodon.internet-czas-dzialac.pl -wss://plushies.social -wss://lnb.openchain.fr -wss://mastodon.lol -wss://social.rebellion.global -wss://ruhr.social -wss://mi.farland.world -wss://pkutalk.com -wss://systemli.social -wss://nostr.minimue81.selfhost.com -wss://mastodon.la -wss://everything.happens.horse -wss://pagan.plus -wss://clacks.link -wss://u-tokyo.social -wss://fediverse.projectftm.com -wss://mastodon.bachgau.social -wss://social.kabi.tk -wss://ty3zdjkwlxo4zah6tgdoolznjcbvkhxpcvjyqe2buxeg23hbeyvr3rad.local -wss://pleroma.wakuwakup.net -wss://social.horrorhub.club -wss://nostr.nightowlstudios.ca -wss://mastodon.ml -wss://relay.orangepillapp.com -wss://misskey.sup39.dev -wss://mfmf.club -wss://pokemon.mastportal.info -wss://gohan-oisii.net -wss://aipi.social -wss://nostr.semisol.com -wss://oslo.town -wss://relay.layer.systems -wss://naharia.net -wss://social.elbespace.de -wss://linuxrocks.online -wss://b81m3pf94ridtry53g8ufyyrjtjaoxgbyjbs5k8qrqkr1whocxiy.loki:8080 -wss://lvl01.tater.ninja -wss://kinky.business -wss://relay.bitblockboom.com -wss://fedi.omada.cafe -wss://social.secret-wg.org -wss://celebrity.social -wss://weirdo.network -wss://mastodon.design -wss://berlin.social -wss://misskey.yukineko.me -wss://mindmachine.688.org -wss://sackheads.social -wss://a.farook.org -wss://social.ridetrans.it -wss://nostr-2.crypticthreadz.com -wss://test.itas.li -wss://fashionsocial.host -wss://ordinary.cafe -wss://social.arinbasu.online -wss://nostr.crypticthreadz.com -wss://relay.zebedee.cloud -wss://nostr.pub.wellorder.net -wss://09d4-5-161-189-144.ngrok-free.app -wss://andalucia.social -wss://udongein.xyz -wss://squeet.me -wss://mastodon.org.uk -wss://guild.pmdcollab.org -wss://relay.fi -wss://black.nostrscity.club -wss://relay.darker.to -wss://denostr.paiya.app -wss://noste.lu.ke -wss://wss.node01.nostress.cc -wss://theverge.space -wss://swiss.social -wss://relay.webstr.org -wss://nostr.shsbt.xyz -wss://relay.nostr.mom -wss://bitcoinmaximlaists.online -wss://relay.openhoofd.nl -wss://toot.aquilenet.fr -wss://toot.ale.gd -wss://relay.devstr.org -wss://lounge.town -wss://amala.schwartzwelt.xyz -wss://planetasieve.com.br -wss://alcrypt.ru:20911 -wss://webzero.grin.plus:8080 -wss://pylons.lightlns.com:28556 -wss://etourneau.fr:28343 -wss://sentie.relay.rts.network -wss://hermes.boarstudios.com -wss://non-central.pw -wss://nostrum.casa -wss://press.coop -wss://neovibe.app -wss://mstdn.starnix.network -wss://nostr.ameristraliagov.com -wss://cryptodon.chat -wss://umbrell.local:4848 -wss://mastodon.codingfield.com -wss://fe.disroot.org -wss://national.catposting.agency -wss://mastodon.pinewoodroad.net -wss://podcasts.social -wss://20nostr.semisol.dev -wss://nostr.oxtr.net -wss://mstdn.o-nature-culture.net -wss://dearcoati6.lnbits.com -wss://die-partei.social -wss://donotban.com -wss://creative.ai -wss://metaskey.net -wss://spacey.space -wss://node01.nostreess.cc -wss://node01.nostress.co -wss://niscii.xyz -wss://3gkpphcfwb6w5iq6axnmlbvr7pz2t37uy4ofocyijzttzrbz4jy43fid.onion -wss://3gkpphcfwb6w5iq6axnmlbvr7pz2t37uy4ofocyijzttzrbz4jy43fid.local -wss://sportsbots.xyz -wss://videos.lukesmith.xyz -wss://nostr.btcfreedom.ca -wss://gardenstate.social -wss://bg-btc.local:4848 -wss://0fa53e299287.ngrok.app -wss://bird.makeup -wss://nlayer.lbdev.fun -wss://relay.queiroz.vip -wss://mstdn.business -wss://osage.moe -wss://botrelay.com -wss://filter.wine -wss://gingadon.com -wss://noncentral.pw -wss://honi.club -wss://xn--baw-joa.social -wss://nostr.semisol.devwss -wss://relay.iris.to -wss://mynostrrelay.deno.dev -wss://social.heise.de -wss://vavursybkbgfyow7nnst5jnqsj2xyteusf3zeerbjdizq6y7h25v4syd.onion:5051 -wss://vavursybkbgfyow7nnst5jnqsj2xyteusf3zeerbjdizq6y7h25v4syd.onion:5050 -wss://relao.nostr.bg -wss://phpc.social -wss://mastodon.kylerank.in -wss://gameliberty.club -wss://rot.gives -wss://www.nostrweb.xyz -wss://ligma.pro -wss://mastodon.grin.hu -wss://geeknews.chat -wss://devdilettante.com -wss://relay.nosr-latam.link -wss://nosr.bitcoiner.social -wss://raru.re -wss://create-key.net -wss://lgbtqia.space -wss://fluffy.family -wss://mas.town -wss://bird.froth.zone -wss://akkoma.cryptoschizo.club -wss://relay.ramus.io -wss://40two.site -wss://relay.40two.site -wss://vis.social -wss://mk.paritybit.ca -wss://nyan.network -wss://ln.weedstr.net -wss://wikis.world -wss://social.fringe.com -wss://umbraxenu.no-ip.biz -wss://heads.social -wss://tsqdakwo4dh5ej3llsi52ftxfbialteu3jm4cmvxaksl3psbyeoyxxqd.onion -wss://jameliris.to -wss://mastodon.thirring.org -wss://miniwa.moe -wss://welcom.nostr.wine -wss://nostr.vulpem.comwss -wss://relay.semisol.dev -wss://kitsunes.club -wss://songbird.cloud -wss://nostr.wyssblitz.org -wss://libera.tokyo -wss://trpger.us -wss://comam.es -wss://nostr-pub.semisol.devaddittoyourrel -wss://social.dev-wiki.de -wss://ostfrie.se -wss://darmstadt.social -wss://nostr.cx.ms -wss://alentours.cc -wss://nostr.kleofash.eu -wss://gib.social -wss://test23.hifish.org -wss://rapemeat.solutions -wss://filter.stealth.winebroadcasttrue -wss://nostr.montre -wss://grumble.social -wss://nostr.roli.social -wss://primarycare.app -wss://hodlr.rocks -wss://superlinks.me -wss://mastodon.lawprofs.org -wss://tictoc.social -wss://nostest.dojotunnel.online -wss://kafka.icu -wss://nostr.lanparty.one -wss://filter.nostr.wineglobaltrue -wss://social.b10m.net -wss://worm.pink -wss://nostr-test.cx.ms -wss://nostrverifired.com -wss://relay.nostrss.re -wss://eliitin-some.fi -wss://dju.social -wss://jeremy.hu -wss://stream.criminallycute.fi -wss://nostr.0x50.dev -wss://n.s.nyc -wss://n.8.s.nyc -wss://relay.rocks -wss://relay.fiatjaf.com -wss://n-lan.s.nyc -wss://sciences.social -wss://nostr.swiss-enigma.com -wss://quietplace.xyz -wss://universe.nostrich.landlangenlangzh -wss://5280.city -wss://feddit.de -wss://base.lc -wss://social.medusmedia.com -wss://umha4zl6xk62a4dous6e7tq4qlmt462hlzs2su33en6qrvtvs3hkjgid.onion -wss://etorneau.fr:28343 -wss://foggyminds.com -wss://neurodiversity-in.au -wss://nostr.hendrixson.net -wss://ramen-fsm.eu.org -wss://www.nostrical.com -wss://feedbeat.me -wss://relay.bsky.social -wss://babka.social -wss://mastodontech.de -wss://commiespace.duckdns.org -wss://openbiblio.social -wss://karkatdyinginagluetrap.com -wss://relay.fundr.vanderwarker.family -wss://hannover.town -wss://nostr.relay.info -wss://mastodon.tetaneutral.net -wss://cache2.primal.net -wss://nostr.petrkr.net -wss://ifwo.eu -wss://mastodong.lol -wss://venera.social -wss://wallets.fyoumoneypod.com -wss://nostr.relay-nokotaro.com -wss://social.exozy.me -wss://gqgjp2bun4opme6mepz3rrgprkw4xatb6h5ogayorqwi6sajsxcp5sad.local -wss://branle.netlify.app -wss://rsslay.fiat.jaf -wss://chrislace.damus.io -wss://relay.leafbodhi.com -wss://social.opendesktop.org -wss://relay.nostr.watch -wss://gearlandia.haus -wss://freiburg.social -wss://atlas.nostro.land -wss://nostr.io -wss://patrizio.tn.al -wss://chaintools.io -wss://kn.icu -wss://relaywithme.eu -wss://la-autopilot-this-end-up.dvm.email -wss://coinfinity.co -wss://assemblag.es -wss://qou7zzll2mxx2ehl73n6pptmhizl5b3entowljlin3sqhcvltxdtlmad.onion -wss://indigenouscreatives.social -wss://bookwyrm.social -wss://kokoro.shugetsu.space -wss://eden.nost.land -wss://misskey.gothloli.club -wss://sersleepy.com -wss://rsslay.nos.pink -wss://pettingzoo.co -wss://witches.live -wss://7craxnzfi42touzi23etut5qjzqro27sqcuottxj7opntcin4fstruad.onion -wss://mastodonsweden.se -wss://mv2k.com -wss://nostr.tchaicap.space -wss://relay.whoop.ph -wss://bitcoiner.nostr.social -wss://kinkyelephant.com -wss://www.superstork.org -wss://mastodon.iftas.org -wss://lea.pet -wss://zug.network -wss://homeserver.local:4848 -wss://frontrange.co -wss://sciencemastodon.com -wss://montereybay.social -wss://social.securecryptomining.com -wss://rssrelay.nostr.moe -wss://nostr.org -wss://nostr.tw -wss://nostr.hk -wss://wxw.moe -wss://mastodonmusic.social -wss://puntarella.party -wss://oyasumi.space -wss://drumstodon.net -wss://social.wikimedia.de -wss://iyasaretai.pw -wss://social.coletivos.org -wss://nostream.localtest.me -wss://ubuntu201.local:4848 -wss://masto.pt -wss://taiwan.riley-tech.net -wss://freeradical.zone -wss://blastrf7z.xyz -wss://onemorestop.photo -wss://frikiverse.zone -wss://toot.bldrweb.org -wss://electroverse.tech -wss://mstdn.games -wss://relay1.nostr.unitedfop.com -wss://gratefuldread.masto.host -wss://mk.gabe.rocks -wss://widerweb.org -wss://mastodon.eternalaugust.com -wss://lay.southeastasia.cloudapp.azure.com:445 -wss://me.ns.ci -wss://gnostr.th -wss://fritter.cn -wss://nex.cn -wss://nex.tw -wss://fritter.jp -wss://fritter.tw -wss://gnostr.cn -wss://mstdn.dk -wss://akkoma.simulacrum-emporium.eu -wss://dalliance.social -wss://toot.re -wss://avatastic.uk -wss://blastr20f7z.xyz -wss://the.voiceover.bar -wss://porcodon.net -wss://nostr.dncn.xyz -wss://nostr.dnxn.xyz -wss://relay.xmr.rocks -wss://d6egak3woofrixu26gr3utb5qezhkktavsuwlrfqaauu55lmpudxudqd.onion:5051 -wss://social.wuebbsy.com -wss://4kgwkcfzea2xhefsquktyxqyjf3rsxa7oo7hxbcs6k3xdpxznknydsqd.local -wss://4kgwkcfzea2xhefsquktyxqyjf3rsxa7oo7hxbcs6k3xdpxznknydsqd.onion -wss://tooters.org -wss://nostre.wine -wss://relay.xplive.local -wss://relay2.xplive.local -wss://4v5umvicfs6a7d3aiy67uu2ibttiuanl2cehfmv5qaorbojousbgkdad.onion -wss://pipou.academy -wss://opjk6jxrcyicuwhe62tqy6zwx776u7rfi6cqo6iodurjvege7piz5wqd.local -wss://mythology.social -wss://nostr.millou.lol -wss://ryogrid.net:7777 -wss://nost.debancariser.com -wss://fault.stsecurity.moe -wss://jan-optiplex-5040.local:4848 -wss://relay-jp.wirednet.jp -wss://mastodon.kitchen -wss://relay.mnethome.de -wss://verkehrswende.social -wss://nostr.gleeze.com -wss://dair-community.social -wss://shota.house -wss://kavlak.uk -wss://social.inex.rocks -wss://4v5umvicfs6a7d3aiy67uu2ibttiuanl2cehfmv5qaorbojousbgkdad.local -wss://startrekshitposting.com -wss://nostrfmar.ddns.net -wss://4yqp7gzuf15zfc3hpwhz3j5p2uarvdsnf75ovpdiqvyjdsmku771jfid.onion -wss://nostrgraph.net -wss://idolheaven.org -wss://mstdn.kemono-friends.info -wss://mastodon.bawue.social -wss://social.pmj.rocks -wss://ursal.zone -wss://nstr.milou.lol -wss://poweredbygay.social -wss://gochisou.photo -wss://lnb3.openchain.fr -wss://nostr.filmweb.pl -wss://nostr-word.h3z.jp -wss://blastr.f7z.xyzanotherinstanceofblastr -wss://shakedown.social -wss://nostr.bubu.hair -wss://hyper-nostr.inosta.cc -wss://ieji.de -wss://wawmartme.com -wss://nostr.kungfu-g.rip -wss://bozgor.org -wss://todon.nl -wss://nostpy.lol -wss://ostatus.taiyolab.com -wss://polsum.rocks -wss://freespeech.group -wss://im.allmendenetz.de -wss://shitpost.poridge.club -wss://twingyeo.kr -wss://social.platypush.tech -wss://rsslay-production-bc22.up.railway.app -wss://lonely.damus.io -wss://kirche.social -wss://cubalibre.social -wss://relay.txinito.xyz -wss://realy.orangepill.dev -wss://3zi.ru -wss://plebstr.com -wss://social.seattle.wa.us -wss://social.bim.land -wss://cubhub.social -wss://relay.nostr-x.com -wss://hispagatos.space -wss://node101.nostress.cc -wss://lsbt.me -wss://jgqaglhautb4k6e6i2g34jakxiemqp6z4wynlirltuukgkft2xuglmqd.onion -wss://nostdemo.dojotunnel.online -wss://f.cz \ No newline at end of file diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index 6214aecc6..d5b2b7ae7 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -20,9 +20,7 @@ */ 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 @@ -30,91 +28,91 @@ import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail -import org.junit.After -import org.junit.Before import org.junit.Test -class AddressableTest { - private lateinit var db: EventStore - +class AddressableTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @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)) + fun testReplacingAddressables() = + forEachDB { db -> + 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)) - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - db.insert(version1) - - db.assertQuery(version1, Filter(ids = listOf(version1.id))) - db.assertQuery(version1, addressableQuery) - - db.insert(version2) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(version2, Filter(ids = listOf(version2.id))) - db.assertQuery(version2, addressableQuery) - - 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))) - db.assertQuery(version3, addressableQuery) - } - - @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)) - - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - - 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(version1, Filter(ids = listOf(version1.id))) + db.assertQuery(version1, addressableQuery) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + db.assertQuery(version2, addressableQuery) + + 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))) + db.assertQuery(version3, addressableQuery) } - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(null, Filter(ids = listOf(version1.id))) - } + @Test + fun testBlockingOldAddressables() = + forEachDB { db -> + 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)) + + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + + 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(version3, addressableQuery) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } @Test - fun testTriggersIndexUsage() { - val explainer = - db.store.explainQuery( + fun testTriggersIndexUsage() = + forEachDB { db -> + val explainer = + db.store.explainQuery( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 30000 AND + event_headers.pubkey = 'aa' AND + event_headers.d_tag = 'test-tag' AND + event_headers.created_at < 1766686500 AND + event_headers.kind >= 30000 AND event_headers.kind < 40000 + """.trimIndent(), + ) + + assertEquals( """ SELECT * FROM event_headers WHERE @@ -123,21 +121,9 @@ class AddressableTest { event_headers.d_tag = 'test-tag' AND event_headers.created_at < 1766686500 AND event_headers.kind >= 30000 AND event_headers.kind < 40000 + └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) """.trimIndent(), + explainer, ) - - assertEquals( - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 30000 AND - event_headers.pubkey = 'aa' AND - event_headers.d_tag = 'test-tag' AND - event_headers.created_at < 1766686500 AND - event_headers.kind >= 30000 AND event_headers.kind < 40000 - └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - """.trimIndent(), - explainer, - ) - } + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt new file mode 100644 index 000000000..6926af8de --- /dev/null +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.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 + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Before + +open class BaseDBTest { + private lateinit var dbs: MutableMap + + fun DefaultIndexingStrategy.name(): String = + """ + indexEventsByCreatedAtAlone=$indexEventsByCreatedAtAlone + indexTagsByCreatedAtAlone=$indexTagsByCreatedAtAlone + indexTagsWithKindAndPubkey=$indexTagsWithKindAndPubkey + useAndIndexIdOnOrderBy=$useAndIndexIdOnOrderBy + """.trimIndent() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + + val booleans = listOf(true, false) + + dbs = mutableMapOf() + + // tests all possible DBs + for (indexEventsByCreatedAtAlone in booleans) { + for (indexTagsByCreatedAtAlone in booleans) { + for (indexTagsWithKindAndPubkey in booleans) { + for (useAndIndexIdOnOrderBy in booleans) { + val indexStrategy = + DefaultIndexingStrategy( + indexEventsByCreatedAtAlone, + indexTagsByCreatedAtAlone, + indexTagsWithKindAndPubkey, + useAndIndexIdOnOrderBy, + ) + dbs[indexStrategy.name()] = + EventStore( + context = context, + dbName = null, + indexStrategy = indexStrategy, + ) + } + } + } + } + } + + @After + fun tearDown() { + dbs.forEach { it.value.close() } + } + + fun forEachDB(action: (EventStore) -> Unit) { + dbs.forEach { + println("--------------------") + println(it.key) + println("--------------------") + action(it.value) + } + } +} diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt index e0a6537bf..256689628 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -30,18 +28,14 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test -class BasicTest { - private lateinit var db: SQLiteEventStore - +class BasicTest : BaseDBTest() { val signer = NostrSignerSync() - companion object Companion { + companion object { val profile = MetadataEvent( id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", @@ -82,168 +76,166 @@ class BasicTest { ) } - @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")) + fun testInsertDeleteEvent() = + forEachDB { db -> + val note = signer.sign(TextNoteEvent.build("test1")) - db.insertEvent(note) + db.store.insertEvent(note) - db.assertQuery(note, Filter(ids = listOf(note.id))) + db.store.assertQuery(note, Filter(ids = listOf(note.id))) - db.delete(note.id) + db.store.delete(note.id) - db.assertQuery(null, Filter(ids = listOf(note.id))) + db.store.assertQuery(null, Filter(ids = listOf(note.id))) - db.insertEvent(note) + db.store.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()) + db.store.assertQuery(note, Filter(ids = listOf(note.id))) } - } @Test - fun hashCodeTest() { - val note1 = - signer.sign( - TextNoteEvent.build("test1") { - hashtag("AaAa") - }, - ) - val note2 = - signer.sign( - TextNoteEvent.build("test2") { - hashtag("AaAa") - }, - ) - val note3 = - signer.sign( - TextNoteEvent.build("test3") { - hashtag("BBBB") - }, - ) + fun testEmptyFilter() = + forEachDB { db -> + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) - db.insertEvent(note1) - db.insertEvent(note2) - db.insertEvent(note3) + db.store.insertEvent(note1) - val list = - db.query( - Filter( - tags = mapOf("t" to listOf("AaAa")), - ), - ) + db.store.assertQuery(note1, Filter()) - assertEquals(2, list.size) - list.forEach { - assertTrue(it.isTaggedHash("AaAa")) + db.store.insertEvent(note2) + + db.store.assertQuery(listOf(note2, note1), Filter()) + } + + @Test + fun testLimitFilter() = + forEachDB { db -> + 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.store.insertEvent(note1) + + db.store.assertQuery(note1, Filter(limit = 1)) + + db.store.insertEvent(note2) + db.store.insertEvent(note3) + db.store.insertEvent(note4) + + db.store.assertQuery(listOf(note4), Filter(limit = 1)) + } + + @Test + fun testPubkeyTag() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery( + comment, + Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))), + ) + } + + @Test + fun testTagOnly() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n")))) + } + + @Test + fun testTagWithSinceOnly() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1), + ) + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt), + ) + db.store.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnly() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1), + ) + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt), + ) + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnlyEmitting() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.query(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event -> + assertEquals(comment.toJson(), event.toJson()) + } + } + + @Test + fun hashCodeTest() = + forEachDB { db -> + val note1 = + signer.sign( + TextNoteEvent.build("test1") { + hashtag("AaAa") + }, + ) + val note2 = + signer.sign( + TextNoteEvent.build("test2") { + hashtag("AaAa") + }, + ) + val note3 = + signer.sign( + TextNoteEvent.build("test3") { + hashtag("BBBB") + }, + ) + + db.store.insertEvent(note1) + db.store.insertEvent(note2) + db.store.insertEvent(note3) + + val list = + db.query( + Filter( + tags = mapOf("t" to listOf("AaAa")), + ), + ) + + assertEquals(2, list.size) + list.forEach { + assertTrue(it.isTaggedHash("AaAa")) + } } - } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt index 1f7f771b3..0a0d9b90a 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -20,9 +20,7 @@ */ 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.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -33,380 +31,378 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase 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 - +class DeletionTest : BaseDBTest() { 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")) + fun testInsertDeleteEvent() = + forEachDB { db -> + 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 exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + 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 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))) } - 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)) + fun testInsertDeleteEventOfAddressable() = + forEachDB { db -> + 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 exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + + 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 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))) } - 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)) + fun testInsertDeleteEventOfAddressable2() = + forEachDB { db -> + 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 exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + 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 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))) } - 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 testInsertDeleteWrap() { - val me = NostrSignerSync() - val myFriend = NostrSignerSync() + fun testInsertDeleteWrap() = + forEachDB { db -> + val me = NostrSignerSync() + val myFriend = NostrSignerSync() - val note1 = me.sign(TextNoteEvent.build("test1")) - val wrap1 = GiftWrapEvent.create(note1, me.pubKey) - val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + val note1 = me.sign(TextNoteEvent.build("test1")) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) - db.insert(wrap1) - db.insert(wrap2) - - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1))) - - db.insert(randomDeletionToWrap) - - db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id))) - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val deletion = me.sign(DeletionEvent.build(listOf(wrap1))) - - db.insert(deletion) - - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - // trying to insert again should fail. - try { db.insert(wrap1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + db.insert(wrap2) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(randomDeletionToWrap) + + db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id))) + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val deletion = me.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) } - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - } + @Test + fun testTriggersIndexUsage() = + forEachDB { db -> + var sql = db.store.deletionModule.rejectDeletedEventsSQLTemplate() + + sql = sql.replace("NEW.etag_hash", "3221122") + sql = sql.replace("NEW.atag_hash", "223322") + sql = sql.replace("NEW.pubkey_owner_hash", "22332323") + sql = sql.replace("NEW.created_at", "1766686500") + + val explainer = db.store.explainQuery(sql) + + if (db.indexStrategy.indexTagsWithKindAndPubkey) { + TestCase.assertEquals( + """ + |$sql + |└── SEARCH event_tags USING COVERING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) + """.trimMargin(), + explainer, + ) + } else { + TestCase.assertEquals( + """ + |$sql + |└── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?) + """.trimMargin(), + explainer, + ) + } + } @Test - fun testTriggersIndexUsage() { - val sql = - """ - SELECT 1 FROM event_tags - INNER JOIN event_headers - ON event_headers.row_id = event_tags.event_header_row_id - WHERE - event_tags.tag_hash IN (3221122, 223322) AND - event_headers.kind = 5 AND - event_headers.created_at >= 1766686500 AND - event_headers.pubkey_owner_hash = 22332323 - """.trimIndent() + fun testDeleteById() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = listOf("ca29c211f", "ca29c211d"), + addresses = emptyList(), + hasher = TagNameValueHasher(0), + ).first() - val explainer = - db.store.explainQuery(sql) - - TestCase.assertEquals( - """ - ${sql.replace("\n","\n ")} - ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - """.trimIndent(), - explainer, - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + id IN ("ca29c211f","ca29c211d") AND + pubkey_owner_hash = "1573573083296714675" + ├── SEARCH event_headers USING INDEX event_headers_id (id=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteById() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = listOf("ca29c211f", "ca29c211d"), - addresses = emptyList(), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteAddressable() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE - id IN ("ca29c211f","ca29c211d") AND - pubkey_owner_hash = "1573573083296714675" - ├── SEARCH event_headers USING INDEX event_headers_id (id=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag = "a") + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteAddressable() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(30000, "key1", "a"), - ), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteAddressablesSingleKind() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30000, "key1", "c"), + Address(30000, "key1", "d"), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE ( - (kind = "30000" AND pubkey = "key1" AND d_tag = "a") - ) AND - kind >= 30000 AND kind < 40000 - ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteAddressablesSingleKind() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(30000, "key1", "a"), - Address(30000, "key1", "b"), - Address(30000, "key1", "c"), - Address(30000, "key1", "d"), - ), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteAddressablesMultipleKinds() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30101, "key1", "c"), + Address(30101, "key1", "d"), + Address(30001, "key2", "e"), + Address(30001, "key2", "f"), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE ( - (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d")) - ) AND - kind >= 30000 AND kind < 40000 - ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b")) + OR + (kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── MULTI-INDEX OR + │ ├── INDEX 1 + │ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + │ └── INDEX 2 + │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteAddressablesMultipleKinds() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(30000, "key1", "a"), - Address(30000, "key1", "b"), - Address(30101, "key1", "c"), - Address(30101, "key1", "d"), - Address(30001, "key2", "e"), - Address(30001, "key2", "f"), - ), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteReplaceables() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(10000, "key1", ""), + Address(10000, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key2", ""), + Address(10001, "key2", ""), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE ( - (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b")) - OR - (kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d")) - ) AND - kind >= 30000 AND kind < 40000 - ├── MULTI-INDEX OR - │ ├── INDEX 1 - │ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - │ └── INDEX 2 - │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } - - @Test - fun testDeleteReplaceables() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(10000, "key1", ""), - Address(10000, "key1", ""), - Address(10001, "key1", ""), - Address(10001, "key1", ""), - Address(10001, "key2", ""), - Address(10001, "key2", ""), - ), - hasher = TagNameValueHasher(0), - ).first() - - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE - kind IN ("10000","10001") AND - pubkey = "key1" AND - ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) - ├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + kind IN ("10000","10001") AND + pubkey = "key1" AND + ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) + ├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt index 6197a8967..0b86746bc 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -20,84 +20,69 @@ */ 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.fail -import org.junit.After import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test -class ExpirationTest { - private lateinit var db: EventStore - +class ExpirationTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testDeletingExpiredEvents() { - val time = TimeUtils.now() + fun testDeletingExpiredEvents() = + forEachDB { db -> + val time = TimeUtils.now() - val noteSafe = - signer.sign( - TextNoteEvent.build("test1", createdAt = time + 1) { - expiration(time + 100) - }, - ) + val noteSafe = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 100) + }, + ) - db.insert(noteSafe) + db.insert(noteSafe) - val noteToExpire = - signer.sign( - TextNoteEvent.build("test1", createdAt = time + 1) { - expiration(time + 1) - }, - ) + val noteToExpire = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 1) + }, + ) - db.insert(noteToExpire) + db.insert(noteToExpire) - db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) + db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) - Thread.sleep(2000) + Thread.sleep(2000) - db.deleteExpiredEvents() + 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) + db.assertQuery(null, Filter(ids = listOf(noteToExpire.id))) + db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id))) + } + + @Test + fun testInsertingExpiredEvents() = + forEachDB { db -> + 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/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt new file mode 100644 index 000000000..88a53e734 --- /dev/null +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 org.junit.Test + +class FilterMatcherTest : BaseDBTest() { + val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758" + val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + val createdAt: Long = 1683596206 + val kind = 1 + + val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" + val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2" + val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac" + val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" + val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3" + val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc" + + val note = + Event( + id, + pubkey, + createdAt, + kind, + arrayOf( + arrayOf("e", rootETag, "", "root"), + arrayOf("e", replyETag, "", "reply"), + arrayOf("p", pTag1), + arrayOf("p", pTag1), + arrayOf("p", pTag2), + arrayOf("p", pTag3), + arrayOf("p", pTag4), + arrayOf("p", pTag5), + arrayOf("p", pTag6), + arrayOf("p", pTag7), + arrayOf("p", pTag8), + ), + "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", + "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", + ) + + @Test + fun matchIds() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(ids = listOf(id))) + db.assertQuery(note, Filter(ids = listOf(id, rootETag))) + db.assertQuery(null, Filter(ids = listOf(rootETag))) + } + + @Test + fun matchPubkeys() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(authors = listOf(pubkey))) + db.assertQuery(note, Filter(authors = listOf(pubkey, rootETag))) + db.assertQuery(null, Filter(authors = listOf(rootETag))) + } + + @Test + fun matchTags() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id)))) + db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id)))) + } + + @Test + fun matchDualTags() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + db.assertQuery(null, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } + + @Test + fun matchAllTags() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1)))) + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id)))) + } + + @Test + fun matchDualAllTags() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } + + @Test + fun matchKinds() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(kinds = listOf(kind))) + db.assertQuery(note, Filter(kinds = listOf(kind, 1221))) + db.assertQuery(null, Filter(kinds = listOf(1221))) + } + + @Test + fun matchSince() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(since = createdAt)) + db.assertQuery(note, Filter(since = createdAt - 1)) + db.assertQuery(null, Filter(since = createdAt + 1)) + } + + @Test + fun matchUntil() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(until = createdAt)) + db.assertQuery(note, Filter(until = createdAt + 1)) + db.assertQuery(null, Filter(until = createdAt - 1)) + } +} diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index a16e2909b..a62de74ee 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.Log import org.junit.After import org.junit.Before +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import java.util.zip.GZIPInputStream @@ -82,6 +83,7 @@ class LargeDBTests { } @Test + @Ignore("Not testing") fun insertDatabase() { events.forEach { event -> try { diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index 2a3983a8d..3dd656c27 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -20,139 +20,208 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import junit.framework.TestCase -import org.junit.After +import junit.framework.TestCase.assertEquals import org.junit.Assert -import org.junit.Before import org.junit.Test -class QueryAssemblerTest { - val hasher = TagNameValueHasher(0L) - val builder = EventIndexesModule(FullTextSearchModule(), { hasher }) - +class QueryAssemblerTest : BaseDBTest() { + val hasher = TagNameValueHasher(0) val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" - private lateinit var db: EventStore + fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - - fun explain(f: Filter) = builder.planQuery(f, hasher, db.store.readableDatabase) - - fun explain(f: List) = builder.planQuery(f, hasher, db.store.readableDatabase) + fun EventStore.explain(f: List) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) @Test - fun testEmpty() { - Assert.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id - └── SCAN event_headers USING INDEX query_by_created_at_id - """.trimIndent(), - explain(Filter()), - ) - } + fun testEmpty() = + forEachDB { db -> + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexEventsByCreatedAtAlone) { + Assert.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY $orderBy + └── SCAN event_headers USING INDEX query_by_created_at_id + """.trimIndent(), + db.explain(Filter()), + ) + } else { + Assert.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY $orderBy + ├── SCAN event_headers + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(Filter()), + ) + } + } @Test - fun testCheckDeletionEventExists() { - val query = - explain( + fun testCheckDeletionEventExists() = + forEachDB { db -> + val filter = Filter( kinds = listOf(5), authors = listOf(key1), tags = mapOf("e" to listOf(key2)), since = 1750889190, - ), - ) - - println(query) - - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_headers.created_at >= "1750889190") AND (event_tags.tag_hash = "2657743813502222172") AND (event_headers.kind = "5") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - query, - ) - } + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsWithKindAndPubkey) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "2657743813502222172") AND (event_tags.kind = "5") AND (event_tags.pubkey_hash = "1730514094536529999") AND (event_tags.created_at >= "1750889190") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "2657743813502222172") AND (event_tags.kind = "5") AND (event_tags.pubkey_hash = "1730514094536529999") AND (event_tags.created_at >= "1750889190") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testLimit() { - val explainer = explain(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 event_headers.created_at DESC, event_headers.id ASC LIMIT 10 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - explainer, - ) - } + fun testLimit() = + forEachDB { db -> + val filter = Filter(limit = 10) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + + if (db.indexStrategy.indexEventsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY $orderBy + LIMIT 10 + └── SCAN event_headers USING INDEX query_by_created_at_id + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY $orderBy + LIMIT 10 + ├── SCAN event_headers + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testLimits() { - val explainer = explain(listOf(Filter(limit = 10), Filter(limit = 30))) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) - UNION - SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ └── COMPOUND QUERY - │ ├── LEFT-MOST SUBQUERY - │ │ ├── CO-ROUTINE (subquery-1) - │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id - │ │ └── SCAN (subquery-1) - │ └── UNION USING TEMP B-TREE - │ ├── CO-ROUTINE (subquery-3) - │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id - │ └── SCAN (subquery-3) - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - explainer, - ) - } + fun testLimits() = + forEachDB { db -> + val filter = listOf(Filter(limit = 10), Filter(limit = 30)) + val orderBy = + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + "created_at DESC, id ASC" + } else { + "created_at DESC" + } + if (db.indexStrategy.indexEventsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ │ └── SCAN (subquery-1) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-3) + │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ └── SCAN (subquery-3) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ ├── SCAN event_headers USING COVERING INDEX query_by_kind_created + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-1) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-3) + │ │ ├── SCAN event_headers USING COVERING INDEX query_by_kind_created + │ │ └── USE TEMP B-TREE FOR ORDER BY + │ └── SCAN (subquery-3) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testAllFeatures() { - val sql = - explain( + fun testAllFeatures() = + forEachDB { db -> + val filter = listOf( Filter(limit = 10), Filter( @@ -162,209 +231,368 @@ class QueryAssemblerTest { 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 row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) - UNION - SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 100) - UNION - SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ └── COMPOUND QUERY - │ ├── LEFT-MOST SUBQUERY - │ │ ├── CO-ROUTINE (subquery-1) - │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id - │ │ └── SCAN (subquery-1) - │ ├── UNION USING TEMP B-TREE - │ │ ├── CO-ROUTINE (subquery-3) - │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: - │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ │ │ └── USE TEMP B-TREE FOR ORDER BY - │ │ └── SCAN (subquery-3) - │ └── UNION USING TEMP B-TREE - │ ├── CO-ROUTINE (subquery-5) - │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: - │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ │ └── USE TEMP B-TREE FOR ORDER BY - │ └── SCAN (subquery-5) - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexEventsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC LIMIT 100) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ │ └── SCAN (subquery-1) + │ ├── UNION USING TEMP B-TREE + │ │ ├── CO-ROUTINE (subquery-3) + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-3) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-5) + │ │ ├── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ │ └── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ └── SCAN (subquery-5) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC LIMIT 100) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ ├── SCAN event_headers USING COVERING INDEX query_by_kind_created + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-1) + │ ├── UNION USING TEMP B-TREE + │ │ ├── CO-ROUTINE (subquery-3) + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-3) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-5) + │ │ ├── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ │ └── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ └── SCAN (subquery-5) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testKind() { - val sql = - explain( + fun testSingleFilterConversionToSimpleQuery() = + forEachDB { db -> + val filter = listOf( Filter( kinds = listOf(ContactListEvent.KIND), 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 event_headers.kind = "3" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + ) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE kind = "3" + ORDER BY created_at DESC, id ASC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE kind = "3" + ORDER BY created_at DESC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testKindAndDTag() { - val sql = - explain( + fun testKinds() = + forEachDB { db -> + val filter = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + limit = 30, + ), + Filter( + kinds = listOf(TextNoteEvent.KIND), + limit = 30, + ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "3" ORDER BY event_headers.created_at DESC LIMIT 30) + UNION + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "1" ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ │ └── SCAN (subquery-1) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-3) + │ │ └── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ └── SCAN (subquery-3) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + + @Test + fun testKindAndDTag() = + forEachDB { db -> + val filter = listOf( Filter( kinds = listOf(ContactListEvent.KIND), tags = mapOf("d" to listOf("")), 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 (event_headers.kind = "3") AND (event_headers.d_tag = "") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + ) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "3") AND (d_tag = "") + ORDER BY created_at DESC, id ASC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "3") AND (d_tag = "") + ORDER BY created_at DESC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testFollowersOf() { - val sql = - explain( - listOf( - Filter( - tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), - limit = 30, - ), - ), - ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } - - @Test - fun testTagsAndKinds() { - val sql = - explain( + fun testFollowersOf() = + forEachDB { db -> + val filter = listOf( Filter( kinds = listOf(ContactListEvent.KIND), tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), limit = 30, ), - ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), ) - - println(sql) - - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.kind = "3") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + } @Test - fun testTagsAndAuthors() { - val sql = - explain( + fun testNotificationsOf() = + forEachDB { db -> + val filter = + listOf( + Filter( + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash (tag_hash=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── USE TEMP B-TREE FOR DISTINCT + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testTagsAndKinds() = + forEachDB { db -> + val filter = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + + @Test + fun testTagsAndAuthors() = + forEachDB { db -> + val filter = listOf( Filter( authors = listOf(key1), tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), limit = 30, ), - ), - ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.pubkey_hash = "1730514094536529999") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash (tag_hash=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.pubkey_hash = "1730514094536529999") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── USE TEMP B-TREE FOR DISTINCT + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testTwoTags() { - val sql = - explain( + fun testTwoTags() = + forEachDB { db -> + val filter = listOf( Filter( kinds = listOf(1), @@ -375,106 +603,418 @@ class QueryAssemblerTest { ), limit = 30, ), - ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsIn1 ON event_tagsIn1.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagsIn1.tag_hash = "-6379614208644810021") AND (event_tags.kind = "1") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ ├── SEARCH event_tagsIn1 USING INDEX query_by_tags_hash (tag_hash=? AND created_at=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsIn1 ON event_tagsIn1.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagsIn1.tag_hash = "-6379614208644810021") AND (event_tags.kind = "1") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ ├── SEARCH event_tagsIn1 USING INDEX fk_event_tags_header_id (event_header_row_id=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testIdQuery() = + forEachDB { db -> + val filter = Filter(ids = listOf(key1)) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + ORDER BY created_at DESC, id ASC + └── SEARCH event_headers USING INDEX event_headers_id (id=?) + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + ORDER BY created_at DESC + └── SEARCH event_headers USING INDEX event_headers_id (id=?) + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testAuthors() = + forEachDB { db -> + val filter = Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("1", "30023")) AND (pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) + ORDER BY created_at DESC, id ASC + LIMIT 300 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("1", "30023")) AND (pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) + ORDER BY created_at DESC + LIMIT 300 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testAuthorsAndSearch() = + forEachDB { db -> + val filter = Filter(authors = listOf(key1, key2, key3), search = "keywords") + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers + INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id + WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) + ORDER BY event_headers.created_at DESC, event_headers.id ASC + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers + INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id + WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) + ORDER BY event_headers.created_at DESC + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testKindAndSearch() = + forEachDB { db -> + val filter = Filter(kinds = listOf(1, 1111, 10000), search = "keywords") + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "event_headers.created_at DESC, event_headers.id ASC" else "event_headers.created_at DESC" + TestCase.assertEquals( + """ + SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers + INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id + WHERE (event_fts MATCH "keywords") AND (event_headers.kind IN ("1", "1111", "10000")) + ORDER BY $orderBy + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_tags as event_tagst ON event_tagst.event_header_row_id = event_tags.event_header_row_id INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagst.tag_hash = "-6379614208644810021") AND (event_headers.kind = "1") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ ├── SEARCH event_tagst USING COVERING INDEX query_by_tags_hash (tag_hash=? AND event_header_row_id=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + } @Test - fun testIdQuery() { - val sql = explain(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 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SEARCH event_headers USING COVERING INDEX event_headers_id (id=?) - └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - """.trimIndent(), - sql, - ) - } + fun testAllTag() = + forEachDB { db -> + val filter = Filter(tagsAll = mapOf("p" to listOf(key1, key2))) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsAll0_1 ON event_tagsAll0_1.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll0_1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "884286737453847614") AND (event_tagsAll0_1.tag_hash = "-4988851810256311323") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── SEARCH event_tagsAll0_1 USING INDEX query_by_tags_hash (tag_hash=? AND created_at=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsAll0_1 ON event_tagsAll0_1.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll0_1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "884286737453847614") AND (event_tagsAll0_1.tag_hash = "-4988851810256311323") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── SEARCH event_tagsAll0_1 USING INDEX fk_event_tags_header_id (event_header_row_id=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testAuthors() { - val sql = explain(Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300)) - 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.kind IN ("1", "30023")) AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 300 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=? AND pubkey=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + fun testReportLikeFilter() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + tags = + mapOf( + "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + limit = 500, + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsWithKindAndPubkey) { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.pubkey_hash = "5446767199141196776") ORDER BY event_tags.created_at DESC LIMIT 500 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.pubkey_hash = "5446767199141196776") ORDER BY event_tags.created_at DESC LIMIT 500 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testAuthorsAndSearch() { - val sql = explain(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_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) AND (event_fts MATCH "keywords") - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + fun testFollowersSinceNov2025() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = + mapOf( + "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + since = 1764553447, // Nov 2025 + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.created_at >= "1764553447") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } @Test - fun testKindAndSearch() { - val sql = explain(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_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111", "10000")) AND (event_fts MATCH "keywords") - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + fun testAllAddressablesOfAKindDownload() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(DraftWrapEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + since = 1764553447, // Nov 2025 + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "31234") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") + ORDER BY $orderBy + └── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) + """.trimIndent(), + db.explain(filter), + ) + } + + @Test + fun testReplaceablesOfMultipleKindsDownloadByDateLimit() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND, SearchRelayListEvent.KIND, AdvertisedRelayListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + limit = 20, + since = 1764553447, // Nov 2025 + ) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") + ORDER BY created_at DESC, id ASC + LIMIT 20 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") + ORDER BY created_at DESC + LIMIT 20 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testReplaceablesOfMultipleKindsDownload() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND, SearchRelayListEvent.KIND, AdvertisedRelayListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ) + + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + ORDER BY created_at DESC, id ASC + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + ORDER BY created_at DESC + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testContactCardDownloadFromTrustedKeys() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(ContactCardEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + tags = + mapOf( + "d" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + since = 1764553447, // Nov 2025 + ) + + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "30382") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") AND ((kind >= 30000 AND kind < 40000)) + ORDER BY created_at DESC, id ASC + ├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "30382") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") AND ((kind >= 30000 AND kind < 40000)) + ORDER BY created_at DESC + ├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index 97c8aa75d..620b5301f 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -20,9 +20,7 @@ */ 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 @@ -30,140 +28,125 @@ import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail -import org.junit.After -import org.junit.Before import org.junit.Test -class ReplaceableTest { - private lateinit var db: EventStore - +class ReplaceableTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @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)) + fun testReplacing() = + forEachDB { db -> + 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)) - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - db.insert(version1) - - db.assertQuery(version1, Filter(ids = listOf(version1.id))) - db.assertQuery(version1, addressableQuery) - - db.insert(version2) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(version2, Filter(ids = listOf(version2.id))) - db.assertQuery(version2, addressableQuery) - - 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))) - db.assertQuery(version3, addressableQuery) - } - - @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)) - - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - - 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(version1, Filter(ids = listOf(version1.id))) + db.assertQuery(version1, addressableQuery) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + db.assertQuery(version2, addressableQuery) + + 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))) + db.assertQuery(version3, addressableQuery) } - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(null, Filter(ids = listOf(version1.id))) - } + @Test + fun testBlockingOldVersions() = + forEachDB { db -> + 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)) + + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + + 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(version3, addressableQuery) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } @Test - fun testTriggersIndexUsageKind0() { - val explainer = - db.store.explainQuery( + fun testTriggersIndexUsageKind0() = + forEachDB { db -> + val sql = """ SELECT * FROM event_headers WHERE event_headers.kind = 0 AND event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); - """.trimIndent(), - ) + event_headers.created_at < 1766686500 + """.trimIndent() - assertEquals( - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 0 AND - event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); - └── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) - """.trimIndent(), - explainer, - ) - } + val explainer = db.store.explainQuery(sql) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 0 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 + └── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at + val sql = """ SELECT * FROM event_headers WHERE event_headers.kind = 3 AND event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); - """.trimIndent(), - ) + event_headers.created_at < 1766686500 + """.trimIndent() - assertEquals( - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 3 AND - event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); - └── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) - """.trimIndent(), - explainer, - ) - } + val explainer = db.store.explainQuery(sql) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 3 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 + └── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at() - 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)) + fun testInsertDeleteEvent() = + forEachDB { db -> + 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.insert(note2) + db.insert(note3) - 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))) - } + 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("wss://quartz.local", 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))) + } @Test - fun testInsertDeleteGiftWrap() { - val time = TimeUtils.now() + fun testInsertDeleteGiftWrap() = + forEachDB { db -> + val time = TimeUtils.now() - val me = NostrSignerSync() - val myFriend = NostrSignerSync() + val me = NostrSignerSync() + val myFriend = NostrSignerSync() - val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time)) - val wrap1 = GiftWrapEvent.create(note1, me.pubKey) - val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time)) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) - db.insert(wrap1) - db.insert(wrap2) - - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) - - db.insert(randomVanishToWrap) - - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val vanish = me.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) - - db.insert(vanish) - - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - // trying to insert again should fail. - try { db.insert(wrap1) - 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.insert(wrap2) - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - } + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2)) + + db.insert(randomVanishToWrap) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val vanish = me.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2)) + + db.insert(vanish) + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + 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(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index f73a3372d..851c95417 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -20,20 +20,14 @@ */ 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 { +class SearchTest : BaseDBTest() { + companion object { val profile = MetadataEvent( id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", @@ -74,35 +68,25 @@ class SearchTest { ) } - @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) + fun testTagWithSearch() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.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.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.store.delete(comment.id) - db.assertQuery(null, Filter(search = "testing")) - db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + db.assertQuery(null, Filter(search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) - db.insertEvent(comment) + db.store.insertEvent(comment) - db.assertQuery(comment, Filter(search = "testing")) - db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) - } + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt index 32e567fd1..6905889e6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -30,30 +30,45 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent class DeletionRequestModule( val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { + fun rejectDeletedEventsSQLTemplate(): String = + if (indexStrategy.indexTagsWithKindAndPubkey) { + """ + |SELECT 1 FROM event_tags + |WHERE + | event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND + | event_tags.kind = 5 AND + | event_tags.pubkey_hash = NEW.pubkey_owner_hash AND + | event_tags.created_at >= NEW.created_at + """.trimMargin() + } else { + """ + |SELECT 1 FROM event_tags + |WHERE + | event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND + | event_tags.kind = 5 AND + | event_tags.created_at >= NEW.created_at AND + | event_tags.pubkey_hash = NEW.pubkey_owner_hash + """.trimMargin() + } + /** * Creates a trigger to reject events that have been * deleted by ID or ATag including GiftWraps that * must be checked against the p-tag (pubkey_owner_hash) */ override fun create(db: SQLiteDatabase) { + val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ") 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 exists') WHERE EXISTS ( - SELECT 1 FROM event_tags - INNER JOIN event_headers - ON event_headers.row_id = event_tags.event_header_row_id - WHERE - event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND - event_headers.kind = 5 AND - event_headers.pubkey_owner_hash = NEW.pubkey_owner_hash AND - event_headers.created_at >= NEW.created_at + $sql ); END; """.trimIndent(), diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index 10fcf8301..8aefa83e4 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -20,23 +20,16 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.Cursor import android.database.sqlite.SQLiteDatabase import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer 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.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.utils.EventFactory class EventIndexesModule( - val fts: FullTextSearchModule, val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, - val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { override fun create(db: SQLiteDatabase) { db.execSQL( @@ -63,19 +56,53 @@ class EventIndexesModule( CREATE TABLE event_tags ( event_header_row_id INTEGER NOT NULL, tag_hash INTEGER NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL, + pubkey_hash INTEGER NOT NULL, FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE ) """.trimIndent(), ) + // queries by ID (load events) db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") - db.execSQL("CREATE INDEX query_by_kind_pubkey_dtag_idx ON event_headers (kind, pubkey, d_tag)") - db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers (created_at desc, id)") - // need to check if this is actually needed. - db.execSQL("CREATE INDEX query_by_created_at_kind_key ON event_headers (created_at desc, kind, pubkey)") + val orderBy = + if (indexStrategy.useAndIndexIdOnOrderBy) { + "created_at DESC, id ASC" + } else { + "created_at DESC" + } + + // queries by limit (latest records), since, until (sync all) alone without any filter by kind.. rare + if (indexStrategy.indexEventsByCreatedAtAlone) { + db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers ($orderBy)") + } + + // queries by kind only, mostly used in Global Feeds when author is not important. + db.execSQL("CREATE INDEX query_by_kind_created ON event_headers (kind, $orderBy)") + + // queries by kind + pubkey, but not d-tag, even if they are replaceables and addressables, by date. + db.execSQL("CREATE INDEX query_by_kind_pubkey_created ON event_headers (kind, pubkey, $orderBy)") + + // makes deletions on the event_header fast db.execSQL("CREATE INDEX fk_event_tags_header_id ON event_tags (event_header_row_id)") - db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, event_header_row_id)") + + // --------------------------------------------------------------------------- + // These next 3 are a very slow indexes (80% of the insert time goes here) + // --------------------------------------------------------------------------- + if (indexStrategy.indexTagsByCreatedAtAlone) { + // First one is only needed if the user is searching by tags without a kind. + db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, created_at DESC)") + } + + // This is the default index for most clients: tags by specific kinds that are supported by the client. + db.execSQL("CREATE INDEX query_by_tags_hash_kind ON event_tags (tag_hash, kind, created_at DESC)") + + // this one is to allow search of tags by kind and author at the same time: NIP-04 DMs, reports, + if (indexStrategy.indexTagsWithKindAndPubkey) { + db.execSQL("CREATE INDEX query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)") + } // Prevent updates to maintain immutability db.execSQL( @@ -117,9 +144,9 @@ class EventIndexesModule( val sqlInsertTags = """ INSERT OR ROLLBACK INTO event_tags - (event_header_row_id, tag_hash) + (event_header_row_id, tag_hash, created_at, kind, pubkey_hash) VALUES - (?,?) + (?,?,?,?,?) """.trimIndent() fun insert( @@ -169,7 +196,7 @@ class EventIndexesModule( // rebalancing the tree every new insert val indexableTags = ArrayList() for (idx in event.tags.indices) { - if (tagIndexStrategy.shouldIndex(event.kind, event.tags[idx])) { + if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) { indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1])) } } @@ -177,387 +204,17 @@ class EventIndexesModule( indexableTags.forEach { stmtTags.bindLong(1, headerId) stmtTags.bindLong(2, it) + stmtTags.bindLong(3, event.createdAt) + stmtTags.bindLong(4, kindLong) + stmtTags.bindLong(5, pubkeyHash) stmtTags.executeInsert() } return headerId } - fun planQuery( - filter: Filter, - hasher: TagNameValueHasher, - db: SQLiteDatabase, - ): String { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher) - - return if (rowIdSubQuery == null) { - val query = makeEverythingQuery() - db.explainQuery(query) - } else { - val query = makeQueryIn(rowIdSubQuery.sql) - db.explainQuery(query, rowIdSubQuery.args.toTypedArray()) - } - } - - fun query( - filter: Filter, - db: SQLiteDatabase, - ): List { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) - - return if (rowIdSubQuery == null) { - db.runQuery(makeEverythingQuery()) - } else { - db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args) - } - } - - fun query( - filter: Filter, - db: SQLiteDatabase, - onEach: (T) -> Unit, - ) { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) - - return if (rowIdSubQuery == null) { - db.runQuery(makeEverythingQuery(), onEach = onEach) - } else { - db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach) - } - } - - fun planQuery( - filters: List, - hasher: TagNameValueHasher, - db: SQLiteDatabase, - ): String { - val rowIdSubQuery = unionSubqueriesIfNeeded(filters, hasher) - - return if (rowIdSubQuery == null) { - val query = makeEverythingQuery() - db.explainQuery(query) - } else { - val query = makeQueryIn(rowIdSubQuery.sql) - db.explainQuery(query, rowIdSubQuery.args.toTypedArray()) - } - } - - fun query( - filters: List, - db: SQLiteDatabase, - ): List { - val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery()) - return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args) - } - - fun query( - filters: List, - db: SQLiteDatabase, - onEach: (T) -> Unit, - ) { - val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) - - if (rowIdSubqueries == null) { - db.runQuery(makeEverythingQuery(), onEach = onEach) - } else { - db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.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 -> - ArrayList(cursor.count).apply { - while (cursor.moveToNext()) { - add(cursor.toEvent()) - } - } - } - - private inline fun SQLiteDatabase.runQuery( - sql: String, - args: List = emptyList(), - onEach: (T) -> Unit, - ) = rawQuery(sql, args.toTypedArray()).use { cursor -> - while (cursor.moveToNext()) { - onEach(cursor.toEvent()) - } - } - - private fun Cursor.toEvent() = - EventFactory.create( - getString(0).intern(), - getString(1).intern(), - getLong(2), - getInt(3), - OptimizedJsonMapper.fromJsonToTagArray(getString(4)), - getString(5), - getString(6), - ) - - class RawEvent( - val id: HexKey, - val pubKey: HexKey, - val createdAt: Long, - val kind: Kind, - val jsonTags: String, - val content: String, - val sig: HexKey, - ) { - fun toEvent() = - EventFactory.create( - id.intern(), - pubKey.intern(), - createdAt, - kind, - OptimizedJsonMapper.fromJsonToTagArray(jsonTags), - content, - sig, - ) - } - - private fun Cursor.toRawEvent() = - RawEvent( - getString(0), - getString(1), - getLong(2), - getInt(3), - getString(4), - getString(5), - getString(6), - ) - - // -------------- - // Counts - // ------------- - fun count( - filter: Filter, - db: SQLiteDatabase, - ): Int { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) - - return if (rowIdSubQuery == null) { - db.countEverything() - } else { - db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) - } - } - - fun count( - filters: List, - db: SQLiteDatabase, - ): Int { - val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything() - - return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.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, hasher(db)) - - return if (rowIdQuery == null) { - 0 - } else { - db.runDelete(rowIdQuery.sql, rowIdQuery.args) - } - } - - fun delete( - filters: List, - db: SQLiteDatabase, - ): Int { - val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0 - - return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args) - } - - private fun SQLiteDatabase.runDelete( - sql: String, - args: List = emptyList(), - ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) - - // --------------------------------- - // Prepare unions of all the filters - // --------------------------------- - fun unionSubqueriesIfNeeded( - filters: List, - hasher: TagNameValueHasher, - ): RowIdSubQuery? { - val inner = - filters.mapNotNull { filter -> - prepareRowIDSubQueries(filter, hasher) - } - - if (inner.isEmpty()) return null - - return if (inner.size == 1) { - inner.first() - } else { - RowIdSubQuery( - sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" }, - args = inner.flatMap { it.args }, - ) - } - } - - // ---------------------------- - // Inner row id selections - // ---------------------------- - fun prepareRowIDSubQueries( - filter: Filter, - hasher: TagNameValueHasher, - ): RowIdSubQuery? { - if (!filter.isFilledFilter()) return null - - val mustJoinSearch = (filter.search != null) - - val nonDTags = filter.tags?.filter { it.key != "d" } ?: emptyMap() - - val hasHeaders = - with(filter) { - (ids != null) || - (authors != null && authors.isNotEmpty()) || - (kinds != null && kinds.isNotEmpty()) || - (tags != null && tags.containsKey("d")) || - (since != null) || - (until != null) || - (limit != null) - } - - var defaultTagKey: String? = null - - val projection = - buildString { - // always do tags if there are any - if (nonDTags.isNotEmpty()) { - append("SELECT event_tags.event_header_row_id as row_id FROM event_tags ") - - // it's quite rare to have 2 tags in the filter, but possible - nonDTags.keys.forEachIndexed { index, tagName -> - if (index > 0) { - append("INNER JOIN event_tags as event_tags$tagName ON event_tags$tagName.event_header_row_id = event_tags.event_header_row_id ") - } else { - defaultTagKey = tagName - } - } - - if (hasHeaders) { - append("INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id ") - } - - if (mustJoinSearch) { - append("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id ") - } - } else if (mustJoinSearch) { - append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName} ") - - if (hasHeaders) { - append("INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") - } - } else { - // no tags and no search. - append("SELECT event_headers.row_id as row_id FROM event_headers ") - } - } - - val clause = - where { - // the order should match indexes - // ids reduce the filter the most - filter.ids?.let { equalsOrIn("event_headers.id", it) } - - // range search is bad but most of the time these are up the top with few elements. - filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } - filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } - - // there are indexes for these, starting with tags. - nonDTags.forEach { (tagName, tagValues) -> - val column = - if (defaultTagKey == null || defaultTagKey == tagName) { - "event_tags.tag_hash" - } else { - "event_tags$tagName.tag_hash" - } - - equalsOrIn( - column, - tagValues.map { - hasher.hash(tagName, it) - }, - ) - } - - filter.kinds?.let { equalsOrIn("event_headers.kind", it) } - filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } - - // there are indexes for these, starting with tags. - filter.tags?.forEach { (tagName, tagValues) -> - if (tagName == "d") { - equalsOrIn("event_headers.d_tag", tagValues) - } - } - - // if search is included, SQLLite will always start here. - filter.search?.let { - if (it.isNotBlank()) { - match(fts.tableName, it) - } - } - } - - val whereClause = - if (filter.limit != null) { - "${clause.conditions} ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}" - } else { - clause.conditions - } - - return RowIdSubQuery("$projection WHERE $whereClause", clause.args) - } - override fun deleteAll(db: SQLiteDatabase) { db.execSQL("DELETE FROM event_tags") db.execSQL("DELETE FROM event_headers") } - - data class RowIdSubQuery( - val sql: String, - val args: List, - ) } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index a4c85102e..53653f55d 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -29,9 +29,9 @@ class EventStore( context: Context, dbName: String? = "events.db", val relayUrl: String? = "wss://quartz.local", - val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IEventStore { - val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy) + val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy) override fun insert(event: Event) = store.insertEvent(event) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt index 4e50b4225..a0a6ea1d4 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt @@ -23,6 +23,64 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.core.Tag interface IndexingStrategy { + /** + * Activate this if you see too many Filters with just LIMIT, SINCE and + * UNTIL filled up. + * + * Clients never support all kinds, so this is usually + * only done with syncing services that must download ALL kinds from + * ALL authors. + * + * The index will make these queries significantly faster, but maybe speed + * is not a requirement on Sync services. The size of this index is + * considerable. + * + * Keep in mind that activating too many indexes increases the size of the + * DB so much that the indexes themselves won't fit in memory, requiring + * frequent reloadings of the index itself from disk. + */ + val indexEventsByCreatedAtAlone: Boolean + + /** + * Activate this if you see too many Tag-centric Filters without + * kind, pubkey or id. + * + * Clients never support all kinds, so this is usually + * only done in rare usecases where the client supports all + * kinds. + * + * The index will make these queries significantly faster, but maybe speed + * is not a requirement on such services. Because this is an index in + * event tags, it becomes QUITE BIG. + * + * Keep in mind that activating too many indexes increases the size of the + * DB so much that the indexes themselves won't fit in memory, requiring + * frequent reloadings of the index itself from disk. + */ + val indexTagsByCreatedAtAlone: Boolean + + /** + * Activate this if you see too many Tag-centric Filters without + * kind AND pubkey at the same time. + * + * This is a rarely used index (reports by your follows or + * NIP-04 DMs for instance) that becomes quite large without + * major gains. + * + * Keep in mind that activating too many indexes increases the size of the + * DB so much that the indexes themselves won't fit in memory, requiring + * frequent reloadings of the index itself from disk. + */ + val indexTagsWithKindAndPubkey: Boolean + + /** + * Activate this to make sure queries are always in order when + * the same created_at exists. This will impact performance and + * the size of indexes, but it provides results that are compliant + * with the Nostr Spec + */ + val useAndIndexIdOnOrderBy: Boolean + fun shouldIndex( kind: Int, tag: Tag, @@ -32,7 +90,12 @@ interface IndexingStrategy { /** * By default, we index all tags that have a single letter name and some value */ -class DefaultIndexingStrategy : IndexingStrategy { +class DefaultIndexingStrategy( + override val indexEventsByCreatedAtAlone: Boolean = false, + override val indexTagsByCreatedAtAlone: Boolean = false, + override val indexTagsWithKindAndPubkey: Boolean = false, + override val useAndIndexIdOnOrderBy: Boolean = false, +) : IndexingStrategy { override fun shouldIndex( kind: Int, tag: Tag, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt new file mode 100644 index 000000000..d4ab144f6 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -0,0 +1,710 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.core.isAddressable +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where +import com.vitorpamplona.quartz.utils.EventFactory + +class QueryBuilder( + val fts: FullTextSearchModule, + val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val indexStrategy: IndexingStrategy, +) { + // ------------ + // Main methods + // ------------ + fun query( + filter: Filter, + db: SQLiteDatabase, + ): List = db.runQuery(toSql(filter, hasher(db))) + + fun query( + filter: Filter, + db: SQLiteDatabase, + onEach: (T) -> Unit, + ) = db.runQuery(toSql(filter, hasher(db)), onEach) + + fun query( + filters: List, + db: SQLiteDatabase, + ): List = db.runQuery(toSql(filters, hasher(db))) + + fun query( + filters: List, + db: SQLiteDatabase, + onEach: (T) -> Unit, + ) = db.runQuery(toSql(filters, hasher(db)), onEach) + + // --------------------------- + // Raw methods for performance + // --------------------------- + fun rawQuery( + filter: Filter, + db: SQLiteDatabase, + ): List = db.runRawQuery(toSql(filter, hasher(db))) + + fun rawQuery( + filter: Filter, + db: SQLiteDatabase, + onEach: (RawEvent) -> Unit, + ) = db.runRawQuery(toSql(filter, hasher(db)), onEach) + + fun rawQuery( + filters: List, + db: SQLiteDatabase, + ): List = db.runRawQuery(toSql(filters, hasher(db))) + + fun rawQuery( + filters: List, + db: SQLiteDatabase, + onEach: (RawEvent) -> Unit, + ) = db.runRawQuery(toSql(filters, hasher(db)), onEach) + + // ----------- + // Debug Tools + // ----------- + fun planQuery( + filter: Filter, + hasher: TagNameValueHasher, + db: SQLiteDatabase, + ): String { + val query = toSql(filter, hasher) + return db.explainQuery(query.sql, query.args.toTypedArray()) + } + + fun planQuery( + filters: List, + hasher: TagNameValueHasher, + db: SQLiteDatabase, + ): String { + val query = toSql(filters, hasher) + return db.explainQuery(query.sql, query.args.toTypedArray()) + } + + fun toSql( + filter: Filter, + hasher: TagNameValueHasher, + ): QuerySpec { + val newFilter = filter.toFilterWithDTags() + + if (newFilter.isSimpleQuery()) { + return makeSimpleQuery( + project = true, + ids = newFilter.ids, + authors = newFilter.authors, + kinds = newFilter.kinds, + dTags = newFilter.dTags, + since = newFilter.since, + until = newFilter.until, + limit = newFilter.limit, + ) + } + + if (newFilter.isSimpleSearch()) { + return makeSimpleSearch( + search = newFilter.search!!, + ids = newFilter.ids, + authors = newFilter.authors, + kinds = newFilter.kinds, + dTags = newFilter.dTags, + since = newFilter.since, + until = newFilter.until, + limit = newFilter.limit, + ) + } + + val rowIdSubqueries = prepareRowIDSubQueries(filter, hasher) + + return if (rowIdSubqueries == null) { + QuerySpec(makeEverythingQuery()) + } else { + QuerySpec( + makeQueryIn(rowIdSubqueries.sql), + rowIdSubqueries.args, + ) + } + } + + fun toSql( + filters: List, + hasher: TagNameValueHasher, + ): QuerySpec { + if (filters.size == 1) return toSql(filters.first(), hasher) + + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher) + + return if (rowIdSubqueries == null) { + QuerySpec( + makeEverythingQuery(), + emptyList(), + ) + } else { + QuerySpec( + makeQueryIn(rowIdSubqueries.sql), + rowIdSubqueries.args, + ) + } + } + + private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}" + + 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${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""} + """.trimIndent() + + private fun SQLiteDatabase.runQuery(query: QuerySpec): List = + rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> + ArrayList(cursor.count).apply { + while (cursor.moveToNext()) { + add(cursor.toEvent()) + } + } + } + + private fun SQLiteDatabase.runRawQuery(query: QuerySpec): List = + rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> + ArrayList(cursor.count).apply { + while (cursor.moveToNext()) { + add(cursor.toRawEvent()) + } + } + } + + private inline fun SQLiteDatabase.runQuery( + query: QuerySpec, + onEach: (T) -> Unit, + ) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> + while (cursor.moveToNext()) { + onEach(cursor.toEvent()) + } + } + + private inline fun SQLiteDatabase.runRawQuery( + query: QuerySpec, + onEach: (RawEvent) -> Unit, + ) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> + while (cursor.moveToNext()) { + onEach(cursor.toRawEvent()) + } + } + + private fun Cursor.toEvent() = + EventFactory.create( + getString(0).intern(), + getString(1).intern(), + getLong(2), + getInt(3), + OptimizedJsonMapper.fromJsonToTagArray(getString(4)), + getString(5), + getString(6), + ) + + private fun Cursor.toRawEvent() = + RawEvent( + getString(0), + getString(1), + getLong(2), + getInt(3), + getString(4), + getString(5), + getString(6), + ) + + // -------------- + // Counts + // ------------- + fun count( + filter: Filter, + db: SQLiteDatabase, + ): Int { + val newFilter = filter.toFilterWithDTags() + + if (newFilter.isSimpleQuery()) { + val sql = + makeSimpleQuery( + project = false, + ids = newFilter.ids, + authors = newFilter.authors, + kinds = newFilter.kinds, + dTags = newFilter.dTags, + since = newFilter.since, + until = newFilter.until, + limit = newFilter.limit, + ) + return db.countIn(sql.sql, sql.args) + } + + val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) + + return if (rowIdSubQuery == null) { + db.countEverything() + } else { + db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) + } + } + + fun count( + filters: List, + db: SQLiteDatabase, + ): Int { + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything() + + return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.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, hasher(db)) + + return if (rowIdQuery == null) { + 0 + } else { + db.runDelete(rowIdQuery.sql, rowIdQuery.args) + } + } + + fun delete( + filters: List, + db: SQLiteDatabase, + ): Int { + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0 + + return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args) + } + + private fun SQLiteDatabase.runDelete( + sql: String, + args: List = emptyList(), + ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) + + // --------------------------------- + // Prepare unions of all the filters + // --------------------------------- + fun unionSubqueriesIfNeeded( + filters: List, + hasher: TagNameValueHasher, + ): QuerySpec? { + val inner = + filters.mapNotNull { filter -> + prepareRowIDSubQueries(filter, hasher) + } + + if (inner.isEmpty()) return null + + return if (inner.size == 1) { + inner.first() + } else { + QuerySpec( + sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" }, + args = inner.flatMap { it.args }, + ) + } + } + + sealed class TagNameForQuery { + class InTags( + val tagName: String, + ) : TagNameForQuery() + + class AllTags( + val tagName: String, + val tagValueIndex: Int, + ) : TagNameForQuery() + } + + // ---------------------------- + // Inner row id selections + // ---------------------------- + fun prepareRowIDSubQueries( + filter: Filter, + hasher: TagNameValueHasher, + ): QuerySpec? { + if (filter.isEmpty()) return null + + val mustJoinSearch = (filter.search != null) + + val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap() + + val nonDTagsAll = filter.tagsAll?.filter { it.key != "d" } ?: emptyMap() + + val reverseLookup = nonDTagsIn.isNotEmpty() || nonDTagsAll.isNotEmpty() + + val needHeaders = + with(filter) { + (ids != null) || (tags != null && tags.containsKey("d")) + } + + val hasHeaders = + with(filter) { + (ids != null) || + (authors != null && authors.isNotEmpty()) || + (kinds != null && kinds.isNotEmpty()) || + (tags != null && tags.containsKey("d")) || + (since != null) || + (until != null) || + (limit != null) + } + + var defaultTagKey: TagNameForQuery? = null + + val projection = + buildString { + // always do tags if there are any + if (reverseLookup) { + append("SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags") + + // it's quite rare to have 2 tags in the filter, but possible + nonDTagsIn.keys.forEachIndexed { index, tagName -> + if (defaultTagKey != null) { + append(" INNER JOIN event_tags as event_tagsIn$index ON event_tagsIn$index.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn$index.created_at = event_tags.created_at") + } else { + defaultTagKey = TagNameForQuery.InTags(tagName) + } + } + + nonDTagsAll.keys.forEachIndexed { index, tagName -> + nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue -> + if (defaultTagKey != null) { + append(" INNER JOIN event_tags as event_tagsAll${index}_$valueIndex ON event_tagsAll${index}_$valueIndex.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll${index}_$valueIndex.created_at = event_tags.created_at") + } else { + defaultTagKey = TagNameForQuery.AllTags(tagName, valueIndex) + } + } + } + + if (needHeaders) { + append(" INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id") + } + + if (mustJoinSearch) { + append(" INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id") + } + } else if (mustJoinSearch) { + append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}") + + if (hasHeaders) { + append(" INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") + } + } else { + // no tags and no search. + append("SELECT event_headers.row_id as row_id FROM event_headers") + } + } + + val clause = + where { + // the order should match indexes + // ids reduce the filter the most + filter.ids?.let { equalsOrIn("event_headers.id", it) } + + // it's quite rare to have 2 tags in the filter, but possible + nonDTagsIn.keys.forEachIndexed { index, tagName -> + val column = + if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.InTags && defaultTagKey.tagName == tagName)) { + "event_tags.tag_hash" + } else { + "event_tagsIn$index.tag_hash" + } + + equalsOrIn( + column, + nonDTagsIn[tagName]!!.map { + hasher.hash(tagName, it) + }, + ) + } + + // there are indexes for these, starting with tags. + nonDTagsAll.keys.forEachIndexed { index, tagName -> + nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue -> + val column = + if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.AllTags && defaultTagKey.tagName == tagName && defaultTagKey.tagValueIndex == valueIndex)) { + "event_tags.tag_hash" + } else { + "event_tagsAll${index}_$valueIndex.tag_hash" + } + + equals(column, hasher.hash(tagName, tagValue)) + } + } + + // range search is bad but most of the time these are up the top with few elements. + if (reverseLookup) { + filter.kinds?.let { equalsOrIn("event_tags.kind", it) } + filter.authors?.let { equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) } + + filter.since?.let { greaterThanOrEquals("event_tags.created_at", it) } + filter.until?.let { lessThanOrEquals("event_tags.created_at", it) } + + // there are indexes for these, starting with tags. + filter.tags?.forEach { (tagName, tagValues) -> + if (tagName == "d") { + equalsOrIn("event_headers.d_tag", tagValues) + } + } + } else { + filter.kinds?.let { equalsOrIn("event_headers.kind", it) } + filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } + + // there are indexes for these, starting with tags. + filter.tags?.forEach { (tagName, tagValues) -> + if (tagName == "d") { + equalsOrIn("event_headers.d_tag", tagValues) + } + } + + filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } + filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } + + // no need to add the replaceable because query_by_kind_pubkey_created already covers it + val isAllAddressable = filter.kinds?.all { it.isAddressable() } ?: false + if (isAllAddressable) { + // matches unique index kind >= 30000 AND kind < 40000 + raw("(event_headers.kind >= 30000 AND event_headers.kind < 40000)") + } + } + + // if search is included, SQLLite will always start here. + filter.search?.let { + if (it.isNotBlank()) { + match(fts.tableName, it) + } + } + } + + val sql = + buildString { + append(projection) + if (clause.conditions.isNotEmpty()) { + append(" WHERE ${clause.conditions}") + } + if (filter.limit != null) { + if (reverseLookup) { + append(" ORDER BY event_tags.created_at DESC") + append(" LIMIT ") + append(filter.limit) + } else { + append(" ORDER BY event_headers.created_at DESC") + append(" LIMIT ") + append(filter.limit) + } + } + } + + return QuerySpec(sql, clause.args) + } + + private fun makeSimpleSearch( + search: String, + ids: List? = null, + authors: List? = null, + kinds: List? = null, + dTags: List? = null, + since: Long? = null, + until: Long? = null, + limit: Int? = null, + ): QuerySpec { + val clause = + where { + // the order should match indexes + // ids reduce the filter the most + ids?.let { equalsOrIn("event_headers.id", it) } + + match(fts.tableName, search) + + kinds?.let { equalsOrIn("event_headers.kind", it) } + authors?.let { equalsOrIn("event_headers.pubkey", it) } + + // there are indexes for these, starting with tags. + dTags?.let { equalsOrIn("event_headers.d_tag", it) } + + since?.let { greaterThanOrEquals("event_headers.created_at", it) } + until?.let { lessThanOrEquals("event_headers.created_at", it) } + + // if this is a dTag filter, it is likely that all kinds are addressables + // and so force the use of the addressable index + if (dTags != null && kinds != null) { + if (kinds.all { it.isAddressable() }) { + // matches unique index kind >= 30000 AND kind < 40000 + raw("(event_headers.kind >= 30000 AND kind < 40000)") + } + } + } + + val sql = + buildString { + append("SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers") + append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") + if (clause.conditions.isNotEmpty()) { + append("\nWHERE ${clause.conditions}") + } + append("\nORDER BY event_headers.created_at DESC") + if (indexStrategy.useAndIndexIdOnOrderBy) { + append(", event_headers.id ASC") + } + if (limit != null) { + append("\nLIMIT ") + append(limit) + } + } + + return QuerySpec(sql, clause.args) + } + + private fun makeSimpleQuery( + project: Boolean, + ids: List? = null, + authors: List? = null, + kinds: List? = null, + dTags: List? = null, + since: Long? = null, + until: Long? = null, + limit: Int? = null, + ): QuerySpec { + val clause = + where { + // the order should match indexes + // ids reduce the filter the most + ids?.let { equalsOrIn("id", it) } + + kinds?.let { equalsOrIn("kind", it) } + authors?.let { equalsOrIn("pubkey", it) } + + // there are indexes for these, starting with tags. + dTags?.let { equalsOrIn("d_tag", it) } + + since?.let { greaterThanOrEquals("created_at", it) } + until?.let { lessThanOrEquals("created_at", it) } + + // if this is a dTag filter, it is likely that all kinds are addressables + // and so force the use of the addressable index + if (dTags != null && kinds != null) { + if (kinds.all { it.isAddressable() }) { + // matches unique index kind >= 30000 AND kind < 40000 + raw("(kind >= 30000 AND kind < 40000)") + } + } + } + + val sql = + buildString { + if (project) { + append("SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers") + } else { + append("SELECT row_id FROM event_headers") + } + if (clause.conditions.isNotEmpty()) { + append("\nWHERE ") + append(clause.conditions) + } + if (project) { + append("\nORDER BY created_at DESC") + if (indexStrategy.useAndIndexIdOnOrderBy) { + append(", id ASC") + } + } + if (limit != null) { + append("\nLIMIT ") + append(limit) + } + } + + return QuerySpec(sql, clause.args) + } + + class FilterWithDTags( + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, + val dTags: List? = null, + val nonDTagsIn: Map>? = null, + val nonDTagsAll: Map>? = null, + val since: Long? = null, + val until: Long? = null, + val limit: Int? = null, + val search: String? = null, + ) { + fun isSimpleSearch() = + search != null && search.isNotEmpty() && + (nonDTagsIn == null || nonDTagsIn.isEmpty()) && + (nonDTagsAll == null || nonDTagsAll.isEmpty()) + + // can be resolved with just event_headers + fun isSimpleQuery() = + (nonDTagsIn == null || nonDTagsIn.isEmpty()) && + (nonDTagsAll == null || nonDTagsAll.isEmpty()) && + (search == null || search.isEmpty()) + } + + fun Filter.toFilterWithDTags(): FilterWithDTags = + FilterWithDTags( + ids = ids, + authors = authors, + kinds = kinds, + dTags = tags?.get("d") ?: tagsAll?.get("d"), + nonDTagsIn = tags?.filter { it.key != "d" }?.ifEmpty { null }, + nonDTagsAll = tagsAll?.filter { it.key != "d" }?.ifEmpty { null }, + since = since, + until = until, + limit = limit, + search = search, + ) + + data class QuerySpec( + val sql: String, + val args: List = emptyList(), + ) +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt index 7aa87e594..9b6a2c964 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -48,8 +48,7 @@ class ReplaceableModule : IModule { WHERE event_headers.kind = NEW.kind AND event_headers.pubkey = NEW.pubkey AND - event_headers.created_at < NEW.created_at AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + event_headers.created_at < NEW.created_at; END; """.trimIndent(), ) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index c1e72a3bb..515d492c4 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -27,10 +27,13 @@ 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.Kind +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.isEphemeral import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip40Expiration.isExpired +import com.vitorpamplona.quartz.utils.EventFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -38,7 +41,7 @@ class SQLiteEventStore( val context: Context, val dbName: String? = "events.db", val relayUrl: String? = null, - val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { companion object { const val DATABASE_VERSION = 2 @@ -47,7 +50,7 @@ class SQLiteEventStore( val seedModule = SeedModule() val fullTextSearchModule = FullTextSearchModule() - val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy) + val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy) val replaceableModule = ReplaceableModule() val addressableModule = AddressableModule() @@ -57,6 +60,8 @@ class SQLiteEventStore( val expirationModule = ExpirationModule() val rightToVanishModule = RightToVanishModule(seedModule::hasher) + val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy) + val modules = listOf( seedModule, @@ -73,6 +78,9 @@ class SQLiteEventStore( override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) + // 32MB memory cache + db.execSQL("PRAGMA cache_size=-32000;") + // makes sure the FKs are sane db.setForeignKeyConstraintsEnabled(true) @@ -82,7 +90,14 @@ class SQLiteEventStore( // The DB can be corrupted if the OS is shutdown before sync, which generally // doesn't happen on Android - db.execSQL("PRAGMA synchronous = OFF") + db.execSQL("PRAGMA synchronous = OFF;") + } + + fun dbSizeMB(): Int { + val f1 = context.getDatabasePath(dbName) + val f2 = context.getDatabasePath("$dbName-wal") + val total = f1.length() + f2.length() + return (total / (1024 * 1024)).toInt() } override fun onCreate(db: SQLiteDatabase) { @@ -171,33 +186,72 @@ class SQLiteEventStore( } } - fun query(filter: Filter): List = eventIndexModule.query(filter, readableDatabase) + fun query(filter: Filter): List = queryBuilder.query(filter, readableDatabase) - fun query(filters: List): List = eventIndexModule.query(filters, readableDatabase) + fun query(filters: List): List = queryBuilder.query(filters, readableDatabase) fun query( filter: Filter, onEach: (T) -> Unit, - ) = eventIndexModule.query(filter, readableDatabase, onEach) + ) = queryBuilder.query(filter, readableDatabase, onEach) fun query( filters: List, onEach: (T) -> Unit, - ) = eventIndexModule.query(filters, readableDatabase, onEach) + ) = queryBuilder.query(filters, readableDatabase, onEach) - fun count(filter: Filter): Int = eventIndexModule.count(filter, readableDatabase) + fun rawQuery(filter: Filter): List = queryBuilder.rawQuery(filter, readableDatabase) - fun count(filters: List): Int = eventIndexModule.count(filters, readableDatabase) + fun rawQuery(filters: List): List = queryBuilder.rawQuery(filters, readableDatabase) + + fun rawQuery( + filter: Filter, + onEach: (RawEvent) -> Unit, + ) = queryBuilder.rawQuery(filter, readableDatabase, onEach) + + fun rawQuery( + filters: List, + onEach: (RawEvent) -> Unit, + ) = queryBuilder.rawQuery(filters, readableDatabase, onEach) + + fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(readableDatabase), readableDatabase) + + fun planQuery(filters: List) = queryBuilder.planQuery(filters, seedModule.hasher(readableDatabase), readableDatabase) + + fun count(filter: Filter): Int = queryBuilder.count(filter, readableDatabase) + + fun count(filters: List): Int = queryBuilder.count(filters, readableDatabase) fun delete(filter: Filter) { - eventIndexModule.delete(filter, writableDatabase) + queryBuilder.delete(filter, writableDatabase) } fun delete(filters: List) { - eventIndexModule.delete(filters, writableDatabase) + queryBuilder.delete(filters, writableDatabase) } fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id)) fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase) } + +class RawEvent( + val id: HexKey, + val pubKey: HexKey, + val createdAt: Long, + val kind: Kind, + val jsonTags: String, + val content: String, + val sig: HexKey, +) { + fun toEvent() = + EventFactory.create( + id.intern(), + pubKey.intern(), + createdAt, + kind, + OptimizedJsonMapper.fromJsonToTagArray(jsonTags), + content, + sig, + ) +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt index c2cd3a6e6..dd4eb90ca 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql sealed class Condition { + data class Raw( + val condition: String, + ) : Condition() + data class Equals( val column: String, val value: Any?, @@ -81,4 +85,6 @@ sealed class Condition { data class Or( val conditions: List, ) : Condition() + + class Empty : Condition() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt index 7e898c244..b109aff26 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt @@ -38,13 +38,27 @@ class SqlSelectionBuilder( */ private fun buildCondition(cond: Condition): String = when (cond) { + is Condition.Empty -> { + "" + } + is Condition.Raw -> { + cond.condition + } is Condition.Equals -> { - selectionArgs.add(cond.value.toString()) - "${cond.column} = ?" + if (cond.value == null) { + "${cond.column} IS NULL" + } else { + selectionArgs.add(cond.value.toString()) + "${cond.column} = ?" + } } is Condition.NotEquals -> { - selectionArgs.add(cond.value.toString()) - "${cond.column} != ?" + if (cond.value == null) { + "${cond.column} IS NULL" + } else { + selectionArgs.add(cond.value.toString()) + "${cond.column} != ?" + } } is Condition.GreaterThan -> { selectionArgs.add(cond.value.toString()) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt index 817ab0ea2..0cb89f573 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql class WhereClauseBuilder { private val conditions = mutableListOf() + fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) } + fun equals( column: String, value: Any?, @@ -86,7 +88,7 @@ class WhereClauseBuilder { fun and(block: WhereClauseBuilder.() -> Unit) = apply { val builder = WhereClauseBuilder().apply(block) - val builtCondition = builder.build() + val builtCondition = builder.buildAnd() if (builtCondition != null) { conditions.add(builtCondition) } @@ -95,22 +97,29 @@ class WhereClauseBuilder { fun or(block: WhereClauseBuilder.() -> Unit) = apply { val builder = WhereClauseBuilder().apply(block) - val builtCondition = builder.build() + val builtCondition = builder.buildOr() if (builtCondition != null) { conditions.add(builtCondition) } } - fun build(): Condition? = + fun buildAnd(): Condition? = when (conditions.size) { 0 -> null 1 -> conditions.first() else -> Condition.And(conditions.toList()) } + + fun buildOr(): Condition? = + when (conditions.size) { + 0 -> null + 1 -> conditions.first() + else -> Condition.Or(conditions.toList()) + } } fun where(block: WhereClauseBuilder.() -> Unit): WhereClause { - val condition = WhereClauseBuilder().apply(block).build() ?: Condition.And(emptyList()) + val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.Empty() return SqlSelectionBuilder(condition).build() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/IForkableEvent.kt similarity index 65% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/IForkableEvent.kt index 0d38a4f5b..e614cf866 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/BaseThreadedEventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/IForkableEvent.kt @@ -20,19 +20,15 @@ */ package com.vitorpamplona.quartz.experimental.forks +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent -import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag -fun BaseThreadedEvent.isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } +@Stable +interface IForkableEvent { + fun isAFork(): Boolean -fun BaseThreadedEvent.forkFromAddress() = - tags.firstOrNull { it.size > 3 && it[0] == "a" && it[3] == "fork" }?.let { - val aTagValue = it[1] - Address.parse(aTagValue) - } + fun forkFromAddress(): Address? -fun BaseThreadedEvent.forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseFork) - -fun BaseThreadedEvent.isForkFromAddressWithPubkey(authorHex: HexKey) = tags.any { it.size > 3 && it[0] == "a" && it[3] == "fork" && it[1].contains(authorHex) } + fun forkFromVersion(): HexKey? +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt index cc2971896..714404777 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/MarkedETagExt.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.forks +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.MARKER @@ -37,3 +38,10 @@ fun MarkedETag.Companion.parseFork(tag: Array): MarkedETag? { ), ) } + +fun MarkedETag.Companion.parseForkedEventId(tag: Array): HexKey? { + if (tag.size < 4 || tag[0] != "e") return null + if (tag[ORDER_MARKER] != MARKER.FORK.code) return null + // ["e", id hex, relay hint, marker, pubkey] + return tag[ORDER_EVT_ID] +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt new file mode 100644 index 000000000..07581a783 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nipsOnNostr + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId +import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +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.types.AddressHint +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.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent +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.nip22Comments.RootScope +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 kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Immutable +class NipTextEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), + AddressableEvent, + RootScope, + EventHintProvider, + AddressHintProvider, + IForkableEvent, + SearchableEvent { + override fun indexableContent() = "title: " + title() + "\n" + content + + override fun dTag() = tags.dTag() + + override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) + + override fun address() = Address(kind, pubKey, dTag()) + + override fun addressTag() = Address.assemble(kind, pubKey, dTag()) + + override fun eventHints(): List { + val qHints = tags.mapNotNull(QTag::parseEventAsHint) + val nip19Hints = citedNIP19().eventHints() + + 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 isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } + + override fun forkFromAddress() = tags.firstNotNullOfOrNull(ForkTag::parseAddress) + + override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId) + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun summary() = + content.lineSequence().drop(1).firstNotNullOfOrNull { + if (it.isBlank() || + it.startsWith("---") || + it.startsWith("`draft") || + it.startsWith("#") + ) { + null + } else { + it + } + } + + companion object { + const val KIND = 30817 + const val KIND_STR = "30817" + + @OptIn(ExperimentalUuidApi::class) + fun build( + description: String, + title: String, + kinds: List, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt("NIP: $title") + + title(title) + kinds(kinds) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/TagArrayBuilderExt.kt new file mode 100644 index 000000000..f77989cfa --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/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.experimental.nipsOnNostr + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.kinds(kinds: List) = addAll(KindTag.assemble(kinds)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt new file mode 100644 index 000000000..27f9547fb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.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.quartz.experimental.nipsOnNostr.tags + +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +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.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class ForkTag( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) { + 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" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], NipTextEvent.KIND_STR) + + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + fun isTagged( + tag: Array, + address: ForkTag, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + fun parse(tag: Array): ForkTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure( + Address.Companion.isOfKind( + tag[1], + CommunityDefinitionEvent.Companion.KIND_STR, + ), + ) { return null } + + val address = Address.Companion.parse(tag[1]) ?: return null + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.Companion.normalizeOrNull(it) } + return ForkTag(address, relayHint) + } + + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure( + Address.Companion.isOfKind( + tag[1], + CommunityDefinitionEvent.Companion.KIND_STR, + ), + ) { return null } + return Address.Companion.parse(tag[1])?.toValue() + } + + fun parseAddress(tag: Array): Address? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val address = Address.parse(tag[1]) ?: return null + ensure(address.kind == NipTextEvent.KIND) { return null } + return address + } + + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure( + Address.isOfKind( + tag[1], + NipTextEvent.KIND_STR, + ), + ) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure( + Address.isOfKind( + tag[1], + NipTextEvent.KIND_STR, + ), + ) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + fun assemble( + aTagId: String, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url, "fork") + + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = assemble(address.toValue(), relay) + + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/TitleTag.kt similarity index 51% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/TitleTag.kt index 4d5d1276d..aa57f067d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/TitleTag.kt @@ -18,38 +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.model.nip18Reposts +package com.vitorpamplona.quartz.experimental.nipsOnNostr.tags -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 +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure -class RepostAction { +class TitleTag { companion object { - suspend fun repost( - note: Note, - signer: NostrSigner, - ): Event? { - val noteEvent = note.event ?: return null + const val TAG_NAME = "title" - 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) + 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] } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt index ca60a06cd..23c9cf33d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt @@ -44,7 +44,13 @@ class AddressSerializer { return try { val parts = addressId.split(":", limit = 3) if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) { - Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "") + if (parts[0].length > 5) { + // invalid kind + Log.w("AddressableId", "Error parsing. invalid kind $addressId") + null + } else { + Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "") + } } else { if (addressId.startsWith("naddr1")) { val addr = Nip19Parser.uriToRoute(addressId)?.entity @@ -60,7 +66,7 @@ class AddressSerializer { } } } catch (t: Throwable) { - Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t) + Log.w("AddressableId", "Error parsing: $addressId: ${t.message}", t) null } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Kind.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Kind.kt index 0aea6ba6f..7bf8cefb3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Kind.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Kind.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent typealias Kind = Int +fun Kind.isRegular() = this > 0 && this != ContactListEvent.KIND && this < 10_000 + fun Kind.isEphemeral() = this >= 20_000 && this < 30_000 fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt index 9e9b6de35..a671865a7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt @@ -54,6 +54,7 @@ object JsonMapper { val jsonInstance = Json { ignoreUnknownKeys = true + isLenient = true } inline fun fromJson(json: String): T = jsonInstance.decodeFromString(json) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index bfbeb2436..2499c8379 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -30,9 +30,6 @@ import kotlinx.serialization.Serializable class UserMetadata { var name: String? = null - @Deprecated("Use name instead", replaceWith = ReplaceWith("name")) - var username: String? = null - @SerialName("display_name") var displayName: String? = null var picture: String? = null @@ -56,16 +53,16 @@ class UserMetadata { @kotlin.jvm.Transient var tags: ImmutableListOfLists? = null - fun anyName(): String? = displayName ?: name ?: username + fun anyName(): String? = displayName ?: name fun anyNameStartsWith(prefix: String): Boolean = - listOfNotNull(name, username, displayName, nip05, lud06, lud16).any { + listOfNotNull(name, displayName, nip05, lud06, lud16).any { it.contains(prefix, true) } fun lnAddress(): String? = lud16 ?: lud06 - fun bestName(): String? = displayName ?: name ?: username + fun bestName(): String? = displayName ?: name fun nip05(): String? = nip05 @@ -78,7 +75,6 @@ class UserMetadata { if (nip05?.isNotEmpty() == true) nip05 = nip05?.trim() if (displayName?.isNotEmpty() == true) displayName = displayName?.trim() if (name?.isNotEmpty() == true) name = name?.trim() - if (username?.isNotEmpty() == true) username = username?.trim() if (lud06?.isNotEmpty() == true) lud06 = lud06?.trim() if (lud16?.isNotEmpty() == true) lud16 = lud16?.trim() if (pronouns?.isNotEmpty() == true) pronouns = pronouns?.trim() @@ -91,7 +87,6 @@ class UserMetadata { if (nip05?.isBlank() == true) nip05 = null if (displayName?.isBlank() == true) displayName = null if (name?.isBlank() == true) name = null - if (username?.isBlank() == true) username = null if (lud06?.isBlank() == true) lud06 = null if (lud16?.isBlank() == true) lud16 = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 4847a747c..ac66e0731 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -36,21 +36,39 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow +/** + * Manages relay subscriptions for the entire pool in a way that only + * sends new states to the relay if they have changed. + * + * This code also awaits a subscription to come to EOSE since many relays + * have through switching subs while they are processing the past. + */ class PoolRequests { /** - * Desired subs and listeners are changed immediately when the local code requests + * Desired subs and listeners + * + * These are changed immediately when the local code requests and should map + * to what the app whants to do. */ private val desiredSubs = LargeCache>>() private val desiredSubListeners = LargeCache() val desiredRelays = MutableStateFlow(setOf()) /** - * relay states are kept and only removed after everything is processed. + * Relay states map what we think the relay is doing + * + * This is important to link responses with which filter was a sub replying to and + * to figure out if we need to update the relay if a REQ has changed. */ private val relayState = LargeCache>() fun subState(subId: String): RequestSubscriptionState = relayState.getOrCreate(subId) { RequestSubscriptionState() } + /** + * This is called when a sub is added or removed from this class and + * should update the desired relay list to get the pool to connect + * to new ones or disconnect to old ones if they are not needed anymore + */ private fun updateRelays() { val myRelays = mutableSetOf() desiredSubs.forEach { sub, perRelayFilters -> @@ -62,19 +80,23 @@ class PoolRequests { } } + /** + * Returns all the active filters for a relay, per subscription + */ fun activeFiltersFor(url: NormalizedRelayUrl): Map> { val myRelays = mutableMapOf>() desiredSubs.forEach { sub, perRelayFilters -> val filters = perRelayFilters[url] if (filters != null) { - myRelays.put(sub, filters) + myRelays[sub] = filters } } return myRelays } /** - * Adds a new filter to the pool, and returns the relays that need to be updated. + * Adds a new subscription to the pool, and returns which relays + * MIGHT need to be updated. */ fun addOrUpdate( subId: String, @@ -98,7 +120,8 @@ class PoolRequests { } /** - * Removes the sub from the pool, and returns the relays that need to be updated. + * Removes the sub from the pool, and returns which relays + * MIGHT need to be updated. */ fun remove(subId: String): Set = if (desiredSubs.containsKey(subId)) { @@ -108,8 +131,6 @@ class PoolRequests { desiredSubs.remove(subId) // update listener desiredSubListeners.remove(subId) - // remove states - relayState.remove(subId) // update relays for pool updateRelays() // return all affected relays @@ -123,12 +144,102 @@ class PoolRequests { // -------------------------- // State management functions // -------------------------- + + /** + * When a connecting, updates the state of all subs + */ fun onConnecting(url: NormalizedRelayUrl) { + // Change states to connecting. relayState.forEach { subId, state -> state.connecting(url) } } + /** + * When a new command is sent to this relay, updates the state + */ + fun onSent( + relay: NormalizedRelayUrl, + cmd: Command, + ) { + when (cmd) { + is ReqCmd -> { + subState(cmd.subId).onOpenReq(relay, cmd.filters) + desiredSubListeners.get(cmd.subId)?.onStartReq( + relay = relay.url, + forFilters = cmd.filters, + ) + } + is CloseCmd -> { + subState(cmd.subId).onCloseReq(relay) + desiredSubListeners.get(cmd.subId)?.onCloseReq( + relay = relay.url, + ) + } + } + } + + /** + * When a new message is received by the relay, updates the sub + */ + fun onIncomingMessage( + relay: IRelayClient, + msg: Message, + ) { + when (msg) { + is EventMessage -> { + val state = relayState.get(msg.subId) + state?.onNewEvent(relay.url) + desiredSubListeners.get(msg.subId)?.onEvent( + event = msg.event, + isLive = state?.currentState(relay.url) == ReqSubStatus.LIVE, + relay = relay.url, + forFilters = state?.lastKnownFilterStates(relay.url), + ) + } + is EoseMessage -> { + val state = relayState.get(msg.subId) + state?.onEose(relay.url) + desiredSubListeners.get(msg.subId)?.onEose( + relay = relay.url, + forFilters = state?.lastKnownFilterStates(relay.url), + ) + + // send a newer version when done + sendToRelayIfChanged(msg.subId, relay.url) { cmd -> + relay.sendOrConnectAndSync(cmd) + } + } + is ClosedMessage -> { + val state = relayState.get(msg.subId) + state?.onClosed(relay.url) + + desiredSubListeners.get(msg.subId)?.onClosed( + message = msg.message, + relay = relay.url, + forFilters = state?.lastKnownFilterStates(relay.url), + ) + + // send a newer version when done + sendToRelayIfChanged(msg.subId, relay.url) { cmd -> + // don't send a close if just closed + if (cmd !is CloseCmd) { + relay.sendOrConnectAndSync(cmd) + } + } + } + } + } + + /** + * When the relay disconnects + */ + fun onDisconnected(url: NormalizedRelayUrl) { + relayState.forEach { subId, state -> + state.disconnected(url) + } + } + fun syncState( relay: NormalizedRelayUrl, sync: (Command) -> Unit, @@ -143,77 +254,6 @@ class PoolRequests { } } - fun onSent( - relay: NormalizedRelayUrl, - cmd: Command, - ) { - when (cmd) { - is ReqCmd -> { - subState(cmd.subId).onOpenReq(relay, cmd.filters) - desiredSubListeners.get(cmd.subId)?.onStartReq( - relay = relay.url, - forFilters = cmd.filters, - ) - } - is CloseCmd -> subState(cmd.subId).onCloseReq(relay) - } - } - - fun onIncomingMessage( - relay: IRelayClient, - msg: Message, - ) { - when (msg) { - is EventMessage -> { - val state = relayState.get(msg.subId) - state?.onNewEvent(relay.url) - desiredSubListeners.get(msg.subId)?.onEvent( - event = msg.event, - isLive = state?.currentState(relay.url) == ReqSubStatus.LIVE, - relay = relay.url, - forFilters = state?.currentFilters(relay.url), - ) - } - is EoseMessage -> { - val state = relayState.get(msg.subId) - state?.onEose(relay.url) - desiredSubListeners.get(msg.subId)?.onEose( - relay = relay.url, - forFilters = state?.currentFilters(relay.url), - ) - - // send a newer version when done - sendToRelayIfChanged(msg.subId, relay.url) { cmd -> - relay.sendOrConnectAndSync(cmd) - } - } - is ClosedMessage -> { - val state = relayState.get(msg.subId) - state?.onClosed(relay.url) - - desiredSubListeners.get(msg.subId)?.onClosed( - message = msg.message, - relay = relay.url, - forFilters = state?.currentFilters(relay.url), - ) - - // send a newer version when done - sendToRelayIfChanged(msg.subId, relay.url) { cmd -> - // don't send a close if just closed - if (cmd !is CloseCmd) { - relay.sendOrConnectAndSync(cmd) - } - } - } - } - } - - fun onDisconnected(url: NormalizedRelayUrl) { - relayState.forEach { subId, state -> - state.disconnected(url) - } - } - /** * If cannot connect, closes subs */ @@ -225,7 +265,7 @@ class PoolRequests { desiredSubListeners.get(subId)?.onCannotConnect( message = errorMessage, relay = url, - forFilters = state.currentFilters(url), + forFilters = state.lastKnownFilterStates(url), ) } } @@ -258,7 +298,8 @@ class PoolRequests { relay: NormalizedRelayUrl, sync: (Command) -> Unit, ) { - val oldFilters = relayState.get(subId)?.currentFilters(relay) + val state = relayState.get(subId) + val oldFilters = state?.currentFilters(relay) val newFilters = desiredSubs.get(subId)?.get(relay) sendToRelayIfChanged(subId, oldFilters, newFilters, sync) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt index ba5b30576..584df7a8f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt @@ -35,10 +35,10 @@ class RelayBasedFilter( fun List.groupByRelay(): Map> { val result = mutableMapOf>() for (relayBasedFilter in this) { - if (relayBasedFilter.filter.isFilledFilter()) { - result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter) - } else { + if (relayBasedFilter.filter.isEmpty()) { Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}") + } else { + result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter) } } return result diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt index 57f114c3e..64d321fc3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/IRequestListener.kt @@ -53,4 +53,6 @@ interface IRequestListener { relay: String, forFilters: List, ) {} + + fun onCloseReq(relay: String) {} } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt index b27dc1efd..7ddb3a2db 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RelayActiveRequestStates.kt @@ -43,7 +43,7 @@ class RelayActiveRequestStates( private val clientListener = object : IRelayClientListener { override fun onConnecting(relay: IRelayClient) { - subStates.put(relay.url, RequestSubscriptionState()) + subStates[relay.url] = RequestSubscriptionState() } override fun onIncomingMessage( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt index f2c7b253b..390265219 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/RequestSubscriptionState.kt @@ -22,6 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.reqs import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +/** + * Manages the State of Subscriptions by logging states as the + * subscription progresses. + */ class RequestSubscriptionState { // Logs the state of each channel to: // 1. inform when an event is received as live @@ -32,36 +36,53 @@ class RequestSubscriptionState { private val subStates = mutableMapOf() private val filterStates = mutableMapOf>() + /** + * This cache is used to make sure we know what the relay was processing + * before a close or disconnect so that if new events still arrive + * we can link them with the appropriate filters. + */ + private val lastKnownFilterStates = mutableMapOf>() + fun currentFilters() = filterStates fun currentFilters(reference: T) = filterStates[reference] + fun lastKnownFilterStates(reference: T) = lastKnownFilterStates[reference] + fun currentState(reference: T) = subStates[reference] fun onNewEvent(reference: T) { if (subStates[reference] == ReqSubStatus.SENT) { - subStates.put(reference, ReqSubStatus.QUERYING_PAST) + subStates[reference] = ReqSubStatus.QUERYING_PAST } } fun onEose(reference: T) { - subStates.put(reference, ReqSubStatus.LIVE) + subStates[reference] = ReqSubStatus.LIVE } fun onClosed(reference: T) { - subStates.put(reference, ReqSubStatus.CLOSED) + subStates[reference] = ReqSubStatus.CLOSED + // Closed messages are usually relays refusing to process a REQ + // This message keeps the state of filterStates intact to + // avoid sending the same filter, and getting immediately closed, + // over and over again. + + // filterStates.remove(reference) } fun onOpenReq( reference: T, filters: List, ) { - subStates.put(reference, ReqSubStatus.SENT) - filterStates.put(reference, filters) + subStates[reference] = ReqSubStatus.SENT + filterStates[reference] = filters + lastKnownFilterStates[reference] = filters } fun onCloseReq(reference: T) { - subStates.put(reference, ReqSubStatus.CLOSED) + subStates[reference] = ReqSubStatus.CLOSED + filterStates.remove(reference) } fun connecting(reference: T) { @@ -70,8 +91,7 @@ class RequestSubscriptionState { } fun disconnected(reference: T) { - // Can't remove filterStates because disconnections happen - // before processing all the events in the channel queue. subStates.remove(reference) + filterStates.remove(reference) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 0d765370c..71e420f13 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -128,7 +128,7 @@ open class BasicRelayClient( } catch (e: Throwable) { if (e is CancellationException) throw e // doesn't expose parsing errors to lib users as errors - Log.e("BasicRelayClient", "Failure to parse message from Relay", e) + Log.e("BasicRelayClient", "Failure to parse message from Relay: $text", e) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index c7576094f..b51b8428c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.stats import androidx.collection.LruCache +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.utils.TimeUtils +@Stable class RelayStat( var receivedBytes: Int = 0, var sentBytes: Int = 0, @@ -31,12 +33,11 @@ class RelayStat( var pingInMs: Int = 0, var compression: Boolean = false, ) { - val messages = LruCache(100) + val messages = LruCache(100) fun newNotice(notice: String?) { val debugMessage = - RelayDebugMessage( - type = RelayDebugMessageType.NOTICE, + NoticeDebugMessage( message = notice ?: "No error message provided", ) @@ -47,8 +48,7 @@ class RelayStat( errorCounter++ val debugMessage = - RelayDebugMessage( - type = RelayDebugMessageType.ERROR, + ErrorDebugMessage( message = error ?: "No error message provided", ) @@ -63,27 +63,35 @@ class RelayStat( sentBytes += bytesUsedInMemory } - fun newSpam(spamDescriptor: String) { + fun newSpam( + link1: String, + link2: String, + ) { spamCounter++ - val debugMessage = - RelayDebugMessage( - type = RelayDebugMessageType.SPAM, - message = spamDescriptor, - ) + val debugMessage = SpamDebugMessage(link1, link2) messages.put(debugMessage, debugMessage) } } -class RelayDebugMessage( - val type: RelayDebugMessageType, - val message: String, - val time: Long = TimeUtils.now(), -) - -enum class RelayDebugMessageType { - SPAM, - NOTICE, - ERROR, +@Stable +sealed interface IRelayDebugMessage { + val time: Long } + +class SpamDebugMessage( + val link1: String, + val link2: String, + override val time: Long = TimeUtils.now(), +) : IRelayDebugMessage + +class NoticeDebugMessage( + val message: String, + override val time: Long = TimeUtils.now(), +) : IRelayDebugMessage + +class ErrorDebugMessage( + val message: String, + override val time: Long = TimeUtils.now(), +) : IRelayDebugMessage diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt index c87bb5158..58930f47f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt @@ -59,9 +59,9 @@ class SubscriptionController( fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) } fun dismissSubscription(subscription: Subscription) { - client.close(subscription.id) subscription.reset() subscriptions.remove(subscription.id) + client.close(subscription.id) } fun updateRelays() { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt index a34cd01fb..eae7fc8e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.utils.Log @@ -35,6 +37,7 @@ import com.vitorpamplona.quartz.utils.Log * - 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). + * - tagsAll: Optional map of tag names to values arrays that must all match (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. @@ -44,10 +47,11 @@ import com.vitorpamplona.quartz.utils.Log * follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs. */ class Filter( - val ids: List? = null, - val authors: List? = null, - val kinds: List? = null, + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, val tags: Map>? = null, + val tagsAll: Map>? = null, val since: Long? = null, val until: Long? = null, val limit: Int? = null, @@ -55,31 +59,33 @@ class Filter( ) : OptimizedSerializable { fun toJson() = OptimizedJsonMapper.toJson(this) - fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, since, until) + fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, tagsAll, since, until) fun copy( ids: List? = this.ids, authors: List? = this.authors, kinds: List? = this.kinds, tags: Map>? = this.tags, + tagsAll: Map>? = this.tagsAll, since: Long? = this.since, until: Long? = this.until, limit: Int? = this.limit, search: String? = this.search, - ) = Filter(ids, authors, kinds, tags, since, until, limit, search) + ) = Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search) /** - * Returns true if this filter contains any non-null and non-empty criteria. + * Returns true if this filter doesn't filter for anything. */ - 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()) + fun isEmpty() = + (ids == null || ids.isEmpty()) && + (authors == null || authors.isEmpty()) && + (kinds == null || kinds.isEmpty()) && + (tags == null || tags.isEmpty() && tags.values.all { it.isNotEmpty() }) && + (tagsAll == null || tagsAll.isEmpty() && tagsAll.values.all { it.isNotEmpty() }) && + (since == null) && + (until == null) && + (limit == null) && + (search == null || search.isEmpty()) init { ids?.forEach { @@ -89,14 +95,27 @@ class Filter( 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()}") + if (tags != null) { + tags["p"]?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") + } + tags["e"]?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") + } + tags["a"]?.forEach { + if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $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()}") + if (tagsAll != null) { + tagsAll["p"]?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") + } + tagsAll["e"]?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") + } + tagsAll["a"]?.forEach { + if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}") + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt index 1367984ee..0539a9d76 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt @@ -29,6 +29,7 @@ object FilterMatcher { authors: List? = null, kinds: List? = null, tags: Map>? = null, + tagsAll: Map>? = null, since: Long? = null, until: Long? = null, ): Boolean { @@ -36,7 +37,23 @@ object FilterMatcher { if (kinds?.contains(event.kind) == false) return false if (authors?.contains(event.pubKey) == false) return false tags?.forEach { tag -> - if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false + val valueSet = tag.value.toSet() + // AND between keys, OR between values + if (!event.tags.any { it.size > 1 && it[0] == tag.key && it[1] in valueSet }) return false + } + tagsAll?.forEach { tag -> + val eventTagValueSet = + event.tags.mapNotNullTo(mutableSetOf()) { + if (it.size > 1 && it[0] == tag.key) { + it[1] + } else { + null + } + } + // AND between keys, AND between values + for (tagValue in tag.value) { + if (tagValue !in eventTagValueSet) return false + } } if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) { return false diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index dd9ec6f7c..1fb5ef244 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.normalizer +import androidx.compose.runtime.Stable + +@Stable data class NormalizedRelayUrl( val url: String, ) : Comparable { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt index 62569ff8d..623c81715 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/kinds/KindTag.kt @@ -44,7 +44,7 @@ class KindTag { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return tag[1].toInt() + return tag[1].toIntOrNull() } fun assemble(kind: Int) = arrayOf(TAG_NAME, kind.toString()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index e58c8d1ce..b9fb1a08c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers @@ -79,7 +80,7 @@ open class BaseThreadedEvent( val tagAddresses = taggedATags() .filter { aTag -> - aTag.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || aTag.kind != WikiNoteEvent.KIND) + aTag.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || aTag.kind != WikiNoteEvent.KIND) && (kind != NipTextEvent.KIND || aTag.kind != NipTextEvent.KIND) // removes forks from itself. }.map { it.toTag() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index f50a5dd48..b8ac14da8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider @@ -47,6 +49,7 @@ import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.json.JsonNull.content @Immutable class TextNoteEvent( @@ -60,6 +63,7 @@ class TextNoteEvent( EventHintProvider, AddressHintProvider, PubKeyHintProvider, + IForkableEvent, SearchableEvent { override fun indexableContent() = "Subject: " + subject() + "\n" + content @@ -109,6 +113,12 @@ class TextNoteEvent( return pHints + nip19Hints } + override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } + + override fun forkFromAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + + override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId) + companion object { const val KIND = 1 const val ALT = "A short note: " diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt index f29779ceb..eaf719fbc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip11RelayInfo import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.text import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer @@ -32,15 +33,14 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive -object FlexibleIntListSerializer : KSerializer?> { - private val listSerializer = ListSerializer(Int.serializer()) +object FlexibleIntListSerializer : KSerializer?> { + private val listSerializer = ListSerializer(String.serializer()) override val descriptor: SerialDescriptor = listSerializer.descriptor - override fun deserialize(decoder: Decoder): List? { + override fun deserialize(decoder: Decoder): List? { require(decoder is JsonDecoder) { "This serializer can only be used with Json format" } return when (val element = decoder.decodeJsonElement()) { @@ -51,7 +51,7 @@ object FlexibleIntListSerializer : KSerializer?> { is JsonArray -> { element.mapNotNull { arrayElement -> try { - arrayElement.jsonPrimitive.int + arrayElement.jsonPrimitive.text } catch (e: Exception) { // Skip elements that aren't valid integers (strings, booleans, floats, etc.) Log.w("FlexibleIntListSerializer", "Invalid element in array: $arrayElement", e) @@ -63,7 +63,7 @@ object FlexibleIntListSerializer : KSerializer?> { // Handle single integer format (malformed but found in the wild): 1 is JsonPrimitive if !element.isString -> { try { - listOf(element.int) + listOf(element.text) } catch (e: Exception) { // Can't parse as integer (e.g., float, boolean), treat as missing data Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e) @@ -79,7 +79,7 @@ object FlexibleIntListSerializer : KSerializer?> { @OptIn(ExperimentalSerializationApi::class) override fun serialize( encoder: Encoder, - value: List?, + value: List?, ) { if (value == null) { encoder.encodeNull() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 593f20494..cdcaad58d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -21,19 +21,22 @@ package com.vitorpamplona.quartz.nip11RelayInfo import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import kotlinx.serialization.Serializable +@Stable @Serializable class Nip11RelayInformation( val id: String? = null, val name: String? = null, val description: String? = null, val icon: String? = null, - val pubkey: String? = null, + val pubkey: HexKey? = null, + val self: HexKey? = null, val contact: String? = null, @Serializable(with = FlexibleIntListSerializer::class) - val supported_nips: List? = null, + val supported_nips: List? = null, val supported_nip_extensions: List? = null, val software: String? = null, val version: String? = null, @@ -42,53 +45,60 @@ class Nip11RelayInformation( val language_tags: List? = null, val tags: List? = null, val posting_policy: String? = null, + val privacy_policy: String? = null, + val terms_of_service: String? = null, val payments_url: String? = null, val retention: List? = null, val fees: RelayInformationFees? = null, val nip50: List? = null, + val supported_grasps: List? = null, ) { companion object { fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson(json) } + + @Stable + @Serializable + class RelayInformationFee( + val amount: Int? = null, + val unit: String? = null, + val period: Int? = null, + val kinds: List? = null, + ) + + @Stable + @Serializable + class RelayInformationFees( + val admission: List? = null, + val subscription: List? = null, + val publication: List? = null, + ) + + @Stable + @Serializable + class RelayInformationLimitation( + val max_message_length: Int? = null, + val max_subscriptions: Int? = null, + val max_filters: Int? = null, + val max_limit: Int? = null, + val default_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, + ) + + @Stable + @Serializable + class RelayInformationRetentionData( + val kinds: ArrayList? = null, + val time: Int? = null, + val count: Int? = null, + ) } - -@Stable -@Serializable -class RelayInformationFee( - val amount: Int? = null, - val unit: String? = null, - val period: Int? = null, - val kinds: List? = null, -) - -@Serializable -class RelayInformationFees( - val admission: List? = null, - val subscription: List? = null, - val publication: List? = null, -) - -@Serializable -class RelayInformationLimitation( - 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, -) - -@Serializable -class RelayInformationRetentionData( - val kinds: ArrayList? = null, - val time: Int? = null, - val count: Int? = null, -) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt index 0e2d3da7c..aa892937a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt @@ -69,7 +69,7 @@ open class BaseDMGroupEvent( return result } - override fun isIncluded(user: HexKey) = pubKey == this.pubKey || tags.any(PTag::isTagged, pubKey) + override fun isIncluded(user: HexKey) = user == this.pubKey || tags.any(PTag::isTagged, user) override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt index 1118b2135..3c4e2ba5d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt @@ -148,6 +148,21 @@ class BookmarkListEvent( ) } + suspend fun remove( + earlierVersion: BookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()), + tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + suspend fun resign( tags: TagArray, privateTags: TagArray, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/TagArrayBuilderExt.kt new file mode 100644 index 000000000..f6715d8fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/TagArrayBuilderExt.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.nip54Wiki + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +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 + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) + +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) + +fun TagArrayBuilder.publishedAt(publishedAt: Long) = addUnique(PublishedAtTag.assemble(publishedAt)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt index ebc6d3a16..e81c56fbe 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -21,9 +21,13 @@ package com.vitorpamplona.quartz.nip54Wiki import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId +import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent 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 @@ -31,13 +35,14 @@ 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.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider -import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip19Bech32.addressHints @@ -46,10 +51,16 @@ 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.nip31Alts.AltTag +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 kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid @Immutable class WikiNoteEvent( @@ -59,12 +70,14 @@ class WikiNoteEvent( tags: Array>, content: String, sig: HexKey, -) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : BaseNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), AddressableEvent, EventHintProvider, AddressHintProvider, PubKeyHintProvider, PublishedAtProvider, + IForkableEvent, + RootScope, SearchableEvent { override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content @@ -124,16 +137,20 @@ class WikiNoteEvent( fun topics() = hashtags() - fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1) + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) - fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) + fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + + override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } + + override fun forkFromAddress() = tags.firstNotNullOfOrNull(ForkTag::parseAddress) + + override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId) override fun publishedAt(): Long? { - val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse) - - if (publishedAt == null) return null + val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse) ?: return null // removes posts in the future. return if (publishedAt <= createdAt) { @@ -146,20 +163,27 @@ class WikiNoteEvent( companion object { const val KIND = 30818 - suspend fun create( - msg: String, - title: String?, - replyTos: List?, - mentions: List?, - signer: NostrSigner, + @OptIn(ExperimentalUuidApi::class) + fun build( + description: String, + title: String, + summary: String? = null, + image: String? = null, + publishedAt: Long? = null, + dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - ): 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")) - return signer.sign(createdAt, KIND, tags.toTypedArray(), msg) - } + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate = + eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt("Wiki entry: $title") + + title(title) + summary?.let { summary(it) } + image?.let { image(it) } + publishedAt?.let { publishedAt(it) } + + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/IPrivateZapsDecryptionCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/IPrivateZapsDecryptionCache.kt new file mode 100644 index 000000000..b826afd03 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/IPrivateZapsDecryptionCache.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.quartz.nip57Zaps + +/** + * Interface for private zap decryption cache. + * Used by Note.kt for checking private zap status. + */ +interface IPrivateZapsDecryptionCache { + fun cachedPrivateZap(event: LnZapRequestEvent): LnZapPrivateEvent? + + suspend fun decryptPrivateZap(event: LnZapRequestEvent): LnZapPrivateEvent? +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt index 9e8ede206..e8f9ec77e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache class PrivateZapCache( signer: NostrSigner, -) { +) : IPrivateZapsDecryptionCache { private val decryptionCache = object : LruCache(1000) { override fun create(key: LnZapRequestEvent): PrivateZapDecryptCache? { @@ -43,9 +43,9 @@ class PrivateZapCache( decryptionCache.remove(event) } - fun cachedPrivateZap(event: LnZapRequestEvent): LnZapPrivateEvent? = decryptionCache[event]?.cached() + override fun cachedPrivateZap(event: LnZapRequestEvent): LnZapPrivateEvent? = decryptionCache[event]?.cached() - suspend fun decryptPrivateZap(event: LnZapRequestEvent) = decryptionCache[event]?.decrypt(event) + override suspend fun decryptPrivateZap(event: LnZapRequestEvent) = decryptionCache[event]?.decrypt(event) } class PrivateZapDecryptCache( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt index c5fc2131e..e03ac1d72 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.quartz.nip94FileMetadata.tags +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure import kotlinx.serialization.Serializable @Serializable +@Stable class DimensionTag( val width: Int, val height: Int, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index ef11c9d3f..2d6162696 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent @@ -266,6 +267,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) + NipTextEvent.KIND -> NipTextEvent(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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcherTest.kt new file mode 100644 index 000000000..ea697b2f0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcherTest.kt @@ -0,0 +1,149 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.filters + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FilterMatcherTest { + val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758" + val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + val createdAt: Long = 1683596206 + val kind = 1 + + val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" + val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2" + val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac" + val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" + val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3" + val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc" + + val note = + Event( + id, + pubkey, + createdAt, + kind, + arrayOf( + arrayOf("e", rootETag, "", "root"), + arrayOf("e", replyETag, "", "reply"), + arrayOf("p", pTag1), + arrayOf("p", pTag1), + arrayOf("p", pTag2), + arrayOf("p", pTag3), + arrayOf("p", pTag4), + arrayOf("p", pTag5), + arrayOf("p", pTag6), + arrayOf("p", pTag7), + arrayOf("p", pTag8), + ), + "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", + "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", + ) + + @Test + fun matchIds() { + assertTrue(note, Filter(ids = listOf(id))) + assertTrue(note, Filter(ids = listOf(id, rootETag))) + assertFalse(note, Filter(ids = listOf(rootETag))) + } + + @Test + fun matchPubkeys() { + assertTrue(note, Filter(authors = listOf(pubkey))) + assertTrue(note, Filter(authors = listOf(pubkey, rootETag))) + assertFalse(note, Filter(authors = listOf(rootETag))) + } + + @Test + fun matchTags() { + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id)))) + assertFalse(note, Filter(tags = mapOf("p" to listOf(id)))) + } + + @Test + fun matchDualTags() { + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + assertFalse(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + assertFalse(note, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } + + @Test + fun matchAllTags() { + assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id)))) + } + + @Test + fun matchDualAllTags() { + assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } + + @Test + fun matchKinds() { + assertTrue(note, Filter(kinds = listOf(kind))) + assertTrue(note, Filter(kinds = listOf(kind, 1221))) + assertFalse(note, Filter(kinds = listOf(1221))) + } + + @Test + fun matchSince() { + assertTrue(note, Filter(since = createdAt)) + assertTrue(note, Filter(since = createdAt - 1)) + assertFalse(note, Filter(since = createdAt + 1)) + } + + @Test + fun matchUntil() { + assertTrue(note, Filter(until = createdAt)) + assertTrue(note, Filter(until = createdAt + 1)) + assertFalse(note, Filter(until = createdAt - 1)) + } + + fun assertTrue( + event: Event, + filter: Filter, + ) = assertTrue(filter.match(event)) + + fun assertFalse( + event: Event, + filter: Filter, + ) = assertFalse(filter.match(event)) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationTest.kt index 3f8653f2d..358474f3f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationTest.kt @@ -74,7 +74,7 @@ class Nip11RelayInformationTest { assertEquals("purplepag.es", info.name) assertEquals("Nostr's Purple Pages", info.description) assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey) - assertEquals(listOf(1, 2, 9, 11), info.supported_nips) + assertEquals(listOf("1", "2", "9", "11"), info.supported_nips) assertEquals("1.0.4", info.version) assertEquals("git+https://github.com/hoytech/strfry.git", info.software) } @@ -88,7 +88,7 @@ class Nip11RelayInformationTest { assertEquals("Nostr's Purple Pages", info.description) assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey) // Single integer should be converted to a list with one element - assertEquals(listOf(1), info.supported_nips) + assertEquals(listOf("1"), info.supported_nips) assertEquals("1.0.4", info.version) assertEquals("git+https://github.com/hoytech/strfry.git", info.software) } @@ -132,7 +132,7 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) // Should skip invalid elements and keep only valid integers - assertEquals(listOf(1, 3, 11), info.supported_nips) + assertEquals(listOf("1", "invalid", "3", "4.5", "true", "11"), info.supported_nips) } @Test @@ -144,7 +144,7 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) // All elements invalid, should result in empty list - assertEquals(emptyList(), info.supported_nips) + assertEquals(listOf("one", "two", "three"), info.supported_nips) } @Test @@ -156,7 +156,7 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) // Cannot parse float as integer, should be null - assertNull(info.supported_nips) + assertEquals(listOf("1.5"), info.supported_nips) } @Test @@ -168,7 +168,7 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) // Cannot parse boolean as integer, should be null - assertNull(info.supported_nips) + assertEquals(listOf("true"), info.supported_nips) } @Test @@ -204,6 +204,6 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) - assertEquals((1..100).toList(), info.supported_nips) + assertEquals((1..100).map { it.toString() }, info.supported_nips) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt index 58eb50846..072c6e5bc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt @@ -106,9 +106,9 @@ class NIP19ParserTest { val actual = Nip19Parser.uriToRoute("nostr:nprofile1qqsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqpzamhxue69uhhv6t5daezumn0wd68yvfwvdhk6tcpz9mhxue69uhkummnw3ezuamfdejj7qgwwaehxw309ahx7uewd3hkctcscpyug") assertNotNull(actual) - assertTrue(actual?.entity is NProfile) + assertTrue(actual.entity is NProfile) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", actual.entity.hex) - assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay?.first()) + assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay.first()) } @Test() @@ -267,7 +267,7 @@ class NIP19ParserTest { "31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", result.aTag(), ) - assertEquals(true, result.relay?.isEmpty()) + assertEquals(true, result.relay.isEmpty()) assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author) assertEquals(31337, result.kind) } @@ -285,7 +285,7 @@ class NIP19ParserTest { "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", result.aTag(), ) - assertEquals(true, result.relay?.isEmpty()) + assertEquals(true, result.relay.isEmpty()) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) assertEquals(30023, result.kind) } @@ -303,7 +303,7 @@ class NIP19ParserTest { "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result.aTag(), ) - assertEquals(true, result.relay?.isEmpty()) + assertEquals(true, result.relay.isEmpty()) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) assertEquals(30023, result.kind) } @@ -356,7 +356,7 @@ class NIP19ParserTest { assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex) assertEquals( NormalizedRelayUrl("wss://nostr.mom/"), - result.relay?.get(0), + result.relay[0], ) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author) assertEquals(1, result.kind) @@ -403,7 +403,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result.hex) - assertEquals("wss://relay.damus.io/", result.relay.get(0)?.url) + assertEquals("wss://relay.damus.io/", result.relay[0].url) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author) assertEquals(1, result.kind) } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt index 26f0ed41d..cd27fd9ad 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt @@ -24,7 +24,6 @@ import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.refTo import platform.Security.SecRandomCopyBytes import platform.Security.kSecRandomDefault -import kotlin.random.Random.Default.nextBytes actual class SecureRandom { actual fun nextInt(): Int { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt index 2dd3c09b1..7ea980703 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt @@ -63,6 +63,10 @@ class EventDeserializer : StdDeserializer(Event::class.java) { } } + if (pubKey.isEmpty()) { + throw IllegalArgumentException("Event not found") + } + return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index a6ca2615b..77c56012a 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -59,7 +59,6 @@ import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer -import kotlinx.serialization.json.JsonNull.content import java.io.InputStream class JacksonMapper { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt index 7b7cbeeca..556dda5de 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt @@ -38,10 +38,16 @@ class FilterDeserializer : StdDeserializer(Filter::class.java) { class ManualFilterDeserializer { companion object { fun fromJson(jsonObject: ObjectNode): Filter { - val tags = mutableListOf() + val tagsIn = mutableListOf() jsonObject.fieldNames().forEach { if (it.startsWith("#")) { - tags.add(it.substring(1)) + tagsIn.add(it.substring(1)) + } + } + val tagsAll = mutableListOf() + jsonObject.fieldNames().forEach { + if (it.startsWith("&")) { + tagsAll.add(it.substring(1)) } } @@ -49,7 +55,8 @@ class ManualFilterDeserializer { ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() }, authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() }, kinds = jsonObject.get("kinds").mapNotNull { it.asIntOrNull() }, - tags = tags.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } }, + tags = tagsIn.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } }, + tagsAll = tagsAll.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } }, since = jsonObject.get("since").asLongOrNull(), until = jsonObject.get("until").asLongOrNull(), limit = jsonObject.get("limit").asIntOrNull(), diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt index afd06b36b..47fdcc674 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt @@ -66,6 +66,16 @@ class FilterSerializer : StdSerializer(Filter::class.java) { } } + filter.tagsAll?.run { + entries.forEach { kv -> + gen.writeArrayFieldStart("&${kv.key}") + for (i in kv.value.indices) { + gen.writeString(kv.value[i]) + } + gen.writeEndArray() + } + } + filter.since?.run { gen.writeNumberField("since", this) } filter.until?.run { gen.writeNumberField("until", this) } filter.limit?.run { gen.writeNumberField("limit", this) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapperTest.kt index e9cf90625..b9cc65956 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapperTest.kt @@ -142,7 +142,7 @@ class JacksonMapperTest { serialized, ) - val deserialized = JacksonMapper.fromJson(serialized) + val deserialized = JacksonMapper.fromJsonToEventTemplate(serialized) assertEquals(followCardTemplate.kind, deserialized.kind) assertEquals(followCardTemplate.createdAt, deserialized.createdAt)