diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index bb5ef6015a..7b4b39125b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -20,6 +20,31 @@ nostrnests reference relay. The IETF code is kept as a reference + unit-test implementation for any future IETF target; see `nestsClient/plans/2026-04-26-moq-lite-gap.md`. +Canonical NIP specs live at — use +`/nip ` to pull a specific one (it fetches the spec file directly). + +## Verify, Don't Guess (standing instruction) + +A plausible-sounding explanation is cheap; being right is not. Before +asserting what a problem is or how something behaves: + +1. **State hypotheses as hypotheses.** If you haven't run it, say "I'm + guessing" or "haven't verified" — never dress an untested guess up as a + diagnosis. Use "I verified X by running Y" only when you actually did. +2. **Reproduce before diagnosing.** If a claim is checkable in under a + minute, check it before stating it. This repo gives you the means: + `./gradlew test`, the per-module tests, and `amy` (the CLI exists partly + to drive `quartz`/`commons` for interop checks). Write the failing case + first, watch it fail, *then* explain. For non-trivial bugs use `/bugfix` + (reproduce-first) or `/investigate` (competing hypotheses + refutation). +3. **Predict, then run.** Before running a command, state the output you + expect. A mismatch is the cheapest signal that your model is wrong. +4. **Don't commit to one cause.** A single immediate explanation stops you + from looking. Hold 2–3 candidates and a discriminating test for each. + +If you find yourself writing paragraphs to defend a theory, that effort +almost always should have been one test. + ## Architecture ``` @@ -40,7 +65,7 @@ amethyst/ │ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine │ ├── jvmAndroid/ # JCA-backed AEAD + UDP socket actuals │ └── commonTest/ # RFC vector + adversarial tests -├── nestsClient/ # Audio-room client (IETF MoQ-transport today; moq-lite phase pending) +├── nestsClient/ # Audio-room client (production runs on moq-lite; IETF MoQ kept as reference) │ └── src/ │ ├── commonMain/ # MoQ session, NestsListener, audio glue │ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack @@ -65,60 +90,33 @@ The global `docs/plans/` folder is frozen — don't add new plans there. ## Tech Stack +Exact versions live in `gradle/libs.versions.toml` (the source of truth — check +there rather than trusting a number copied here). + | Layer | Technology | |-------|------------| | **Core** | Quartz (Nostr KMP) | -| **UI** | Compose Multiplatform 1.10.3 | +| **UI** | Compose Multiplatform | | **Async** | kotlinx.coroutines + Flow | | **Network** | OkHttp (JVM) | | **Serialization** | Jackson | | **DI** | Manual / Koin | -| **Build** | Gradle 8.x, Kotlin 2.3.20 | +| **Build** | Gradle + Kotlin Multiplatform | ## Skills -Specialized skills provide domain expertise with bundled resources and patterns: +The full list of available skills (with descriptions and triggers) is injected +into every session, so it isn't duplicated here. Two kinds exist and are meant +to be used together: -| Skill | Expertise | When to Use | -|-------|-----------|-------------| -| `nostr-expert` | Nostr protocol (Quartz library) | Event types, NIPs, tags, signing, Bech32, NIP-44, LargeCache | -| `kotlin-expert` | Advanced Kotlin patterns | StateFlow, sealed classes, @Immutable, DSLs, common utilities | -| `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, rich-text parsing | -| `android-expert` | Android platform | Navigation, permissions, lifecycle, Material3, Coil image loading | -| `desktop-expert` | Desktop platform | Window, MenuBar, keyboard shortcuts, DeckLayout | -| `gradle-expert` | Build system | Dependencies, versioning, packaging, optimization | -| `account-state` | `Account` + `LocalCache` | Per-user StateFlows, event store, adding account-scoped settings | -| `relay-client` | Subscriptions & filter assembly | `ComposeSubscriptionManager`, assemblers, preloaders, EOSE | -| `feed-patterns` | Feeds & DAL | `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` family | -| `auth-signers` | `NostrSigner` implementations | Local, NIP-46 bunker, NIP-55 Android external signer | -| `quartz-integration` | Quartz as an external library | Gradle setup, `NostrClient`, `KeyPair`, for external projects | -| `amy-expert` | Amy CLI (`cli/` module) | Adding `amy ` commands, JSON output contract, extracting logic from `amethyst/` into `commons/` so CLI can call it | -| `find-missing-translations` | Utility | Extract untranslated Android strings | -| `find-non-lambda-logs` | Utility | Audit Log calls for lambda overloads | - -### Technique-layer skills (Compose / Kotlin best practices) - -These are general Compose/Kotlin decision-framework skills (vendored from -`chrisbanes/skills`). The skills above are **codebase-oriented** ("where is X in -Amethyst, what pattern do we use"); these are **technique-oriented** ("what is -the correct Compose/Kotlin design here"). They complement — not replace — the -codebase skills: e.g. `compose-expert` tells you where shared composables live, -`compose-slot-api-pattern` tells you how to shape their public API. - -| Skill | Expertise | Complements | -|-------|-----------|-------------| -| `compose-recomposition-performance` | Router: which recomposition axis is the problem | `compose-expert` | -| `compose-stability-diagnostics` | Compiler reports, strong skipping, `ImmutableList` at UI boundaries | `compose-expert`, `kotlin-expert` | -| `compose-state-deferred-reads` | Phase-aware state reads, block-form modifiers, provider lambdas | `compose-expert` | -| `compose-slot-api-pattern` | `@Composable` slot design for reusable components | `compose-expert` | -| `compose-modifier-and-layout-style` | `modifier` parameter conventions, chain construction, conditional hoisting | `compose-expert` | -| `compose-side-effects` | `LaunchedEffect`/`DisposableEffect`/`SideEffect`, keys, `rememberUpdatedState` | `compose-expert` | -| `compose-state-holder-ui-split` | State-holder vs plain-UI composable split | `compose-expert`, `feed-patterns` | -| `kotlin-flow-state-event-modeling` | StateFlow/SharedFlow/Channel choice, sentinels, `stateIn`, `update {}` | `kotlin-expert` | -| `kotlin-coroutines-structured-concurrency` | Stored-scope anti-pattern, `suspend` boundaries, `runBlocking`, cancellation | `kotlin-coroutines` | -| `kotlin-types-value-class` | `@JvmInline value class` vs `data class`, Compose stability | `kotlin-expert` | +- **Codebase-oriented** skills (`nostr-expert`, `compose-expert`, `feed-patterns`, + `account-state`, `amy-expert`, …) answer "where is X in Amethyst, what pattern + do we use here." +- **Technique-oriented** skills (vendored from `chrisbanes/skills`, e.g. + `compose-slot-api-pattern`, `kotlin-flow-state-event-modeling`) answer "what is + the correct Compose/Kotlin design." They complement, not replace, the codebase + skills: `compose-expert` tells you where shared composables live; + `compose-slot-api-pattern` tells you how to shape their public API. ## Workflow @@ -130,126 +128,42 @@ codebase skills: e.g. `compose-expert` tells you where shared composables live, 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 -- `/nip ` - Get NIP implementation guidance - ## Feature Workflow -**CRITICAL: Always check existing implementations first before creating new code!** +**CRITICAL: Check existing implementations first — most logic already exists.** +Before writing code, survey all modules (use Grep/Explore) for managers, caches, +state systems, filters, ViewModels, and composables that already do the job. Your +job is usually to **reuse** (`quartz` protocol/business logic), **extract** +(Android UI/ViewModels → `commons`), and add **platform-specific** layouts/nav — +not to duplicate existing managers, caches, or state. -When picking up a new task or feature, follow this process: +Capture the survey as a matrix in your plan: -### Step 0: Survey Existing Implementation (MANDATORY) +| File/Component | Status | Location | Action | +|----------------|--------|----------|--------| +| FilterBuilders | ✅ Reuse | quartz/relay/filters/ | Use as-is | +| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons | +| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern | -**Before writing ANY code, thoroughly audit ALL modules:** +**Legend:** ✅ Reuse (exists, use directly) · 📦 Extract (exists in Android, move +to `commons`) · 🆕 New (doesn't exist — platform-specific only) · ⚠️ Avoid +(duplicate; use existing pattern). -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/ +**Share vs keep platform-native:** - # Search in commons for UI components - grep -r "@Composable.*Card\|@Composable.*View\|@Composable.*Dialog" commons/src/ +- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and + `commons/commonMain/` (major UI components, **ViewModels** under + `viewmodels/`, icons). ViewModels are platform-agnostic state + logic + (StateFlow/SharedFlow), so they belong in `commons`. +- **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android + `Activity`), navigation (sidebar vs bottom nav), platform interactions + (gestures, keyboard shortcuts), system integrations (notifications, file + pickers). - # 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 - -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 - -Before coding, create a plan that categorizes work into three buckets: - -| Category | Description | Location | -|----------|-------------|----------| -| **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/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) -- 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. 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. +When extracting a composable: move it to `commons/commonMain/` (see +`/compose-expert`), add expect/actual for any platform behavior (see +`/kotlin-multiplatform`), then point both Android and Desktop at the shared +version. `quartz/` is protocol-only — no composables. ## Build Commands @@ -272,39 +186,9 @@ When extracting UI components: ## Quartz KMP Structure -The Quartz library uses expect/actual for platform-specific implementations: - -```kotlin -// commonMain - shared protocol logic -expect class CryptoProvider { - fun sign(message: ByteArray, privateKey: ByteArray): ByteArray - fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean -} - -// androidMain - uses secp256k1-kmp-jni-android -actual class CryptoProvider { /* Android implementation */ } - -// jvmMain - uses secp256k1-kmp-jni-jvm -actual class CryptoProvider { /* JVM implementation */ } -``` - -## Key Patterns - -### Platform Abstraction -```kotlin -// commonMain -expect fun openExternalUrl(url: String) - -// androidMain -actual fun openExternalUrl(url: String) { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) -} - -// jvmMain (Desktop) -actual fun openExternalUrl(url: String) { - Desktop.getDesktop().browse(URI(url)) -} -``` +Quartz uses expect/actual for platform-specific implementations (e.g. crypto +backed by `secp256k1-kmp-jni-android` on Android and `secp256k1-kmp-jni-jvm` on +JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns. ## Icons @@ -352,12 +236,5 @@ Do this before considering the task complete. ## Git Workflow -- Branch: `feat/desktop-` or `fix/desktop-` - Commits: Conventional commits (`feat:`, `fix:`, etc.) - Never use `--no-verify` - -## Resources - -- [Nostr NIPs](https://github.com/nostr-protocol/nips) -- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) -- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9ed078f30..5997aaaed0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 # Remote Gradle build cache: writes on push to main, reads on PRs and @@ -71,7 +71,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle @@ -203,7 +203,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle @@ -256,7 +256,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 17b7b64c13..7c5cdf1b47 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -55,7 +55,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Resolve tag + version @@ -223,7 +223,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Resolve tag + version @@ -346,7 +346,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Cache gradle diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 5e1d7325ca..b5eb3a1355 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -30,7 +30,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle @@ -60,7 +60,7 @@ jobs: - name: Set up JDK 21 uses: actions/setup-java@v5 with: - distribution: 'zulu' + distribution: 'temurin' java-version: 21 - name: Set up Gradle diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 5b829b986a..3645439400 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -114,6 +114,8 @@ private object PrefKeys { const val DEFAULT_ARTICLES_FOLLOW_LIST = "defaultArticlesFollowList" const val DEFAULT_MUSIC_TRACKS_FOLLOW_LIST = "defaultMusicTracksFollowList" const val DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST = "defaultMusicPlaylistsFollowList" + const val DEFAULT_PODCAST_EPISODES_FOLLOW_LIST = "defaultPodcastEpisodesFollowList" + const val DEFAULT_PODCASTS_FOLLOW_LIST = "defaultPodcastsFollowList" const val DEFAULT_SOFTWARE_APPS_FOLLOW_LIST = "defaultSoftwareAppsFollowList" const val DEFAULT_BADGES_FOLLOW_LIST = "defaultBadgesFollowList" const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList" @@ -385,6 +387,8 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultArticlesFollowList.value)) putString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicTracksFollowList.value)) putString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicPlaylistsFollowList.value)) + putString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastEpisodesFollowList.value)) + putString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastsFollowList.value)) putString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultSoftwareAppsFollowList.value)) putString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBadgesFollowList.value)) putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value)) @@ -681,6 +685,8 @@ object LocalPreferences { defaultLongsFollowList = MutableStateFlow(followListPrefs.longs), defaultMusicTracksFollowList = MutableStateFlow(followListPrefs.musicTracks), defaultMusicPlaylistsFollowList = MutableStateFlow(followListPrefs.musicPlaylists), + defaultPodcastEpisodesFollowList = MutableStateFlow(followListPrefs.podcastEpisodes), + defaultPodcastsFollowList = MutableStateFlow(followListPrefs.podcasts), defaultArticlesFollowList = MutableStateFlow(followListPrefs.articles), defaultSoftwareAppsFollowList = MutableStateFlow(followListPrefs.softwareApps), defaultBadgesFollowList = MutableStateFlow(followListPrefs.badges), @@ -759,6 +765,8 @@ object LocalPreferences { val articles: TopFilter, val musicTracks: TopFilter, val musicPlaylists: TopFilter, + val podcastEpisodes: TopFilter, + val podcasts: TopFilter, val softwareApps: TopFilter, val badges: TopFilter, val browseEmojiSets: TopFilter, @@ -784,6 +792,8 @@ object LocalPreferences { articles = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, null), TopFilter.AllFollows), musicTracks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, null), TopFilter.Global), musicPlaylists = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, null), TopFilter.Global), + podcastEpisodes = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, null), TopFilter.Global), + podcasts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, null), TopFilter.Global), softwareApps = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, null), TopFilter.Global), badges = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, null), TopFilter.Mine), browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global), 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 369659698d..407cd48b97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -553,6 +553,12 @@ class Account( val liveMusicPlaylistsFollowLists: StateFlow = topNavFilterFlow(settings.defaultMusicPlaylistsFollowList) val liveMusicPlaylistsFollowListsPerRelay = OutboxLoaderState(liveMusicPlaylistsFollowLists, cache, scope).flow + val livePodcastEpisodesFollowLists: StateFlow = topNavFilterFlow(settings.defaultPodcastEpisodesFollowList) + val livePodcastEpisodesFollowListsPerRelay = OutboxLoaderState(livePodcastEpisodesFollowLists, cache, scope).flow + + val livePodcastsFollowLists: StateFlow = topNavFilterFlow(settings.defaultPodcastsFollowList) + val livePodcastsFollowListsPerRelay = OutboxLoaderState(livePodcastsFollowLists, cache, scope).flow + val liveSoftwareAppsFollowLists: StateFlow = topNavFilterFlow(settings.defaultSoftwareAppsFollowList) val liveSoftwareAppsFollowListsPerRelay = OutboxLoaderState(liveSoftwareAppsFollowLists, cache, scope).flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index f7c8de8f27..7e53a66456 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -177,6 +177,8 @@ class AccountSettings( val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultMusicTracksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultMusicPlaylistsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultPodcastEpisodesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultPodcastsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultSoftwareAppsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -671,6 +673,28 @@ class AccountSettings( } } + fun changeDefaultPodcastEpisodesFollowList(name: FeedDefinition) { + changeDefaultPodcastEpisodesFollowList(name.code) + } + + fun changeDefaultPodcastEpisodesFollowList(name: TopFilter) { + if (defaultPodcastEpisodesFollowList.value != name) { + defaultPodcastEpisodesFollowList.tryEmit(name) + saveAccountSettings() + } + } + + fun changeDefaultPodcastsFollowList(name: FeedDefinition) { + changeDefaultPodcastsFollowList(name.code) + } + + fun changeDefaultPodcastsFollowList(name: TopFilter) { + if (defaultPodcastsFollowList.value != name) { + defaultPodcastsFollowList.tryEmit(name) + saveAccountSettings() + } + } + fun changeDefaultSoftwareAppsFollowList(name: FeedDefinition) { changeDefaultSoftwareAppsFollowList(name.code) } 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 e3916a5e65..c345bbeb78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -271,6 +271,10 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -2219,7 +2223,10 @@ object LocalCache : ILocalCache, ICacheProvider { val requestId = event.requestId() val pending = when (val match = paymentTracker.onResponseReceived(requestId, event.pubKey)) { - is NwcPaymentTracker.MatchResult.Matched -> match.pending + is NwcPaymentTracker.MatchResult.Matched -> { + match.pending + } + is NwcPaymentTracker.MatchResult.WrongAuthor -> { // Possible spoof: a kind-23195 event from someone other than // the wallet service we sent the request to. The pending @@ -2232,6 +2239,7 @@ object LocalCache : ILocalCache, ICacheProvider { ) return false } + NwcPaymentTracker.MatchResult.NoMatch -> { Log.w( "LocalCache", @@ -3007,23 +3015,24 @@ object LocalCache : ILocalCache, ICacheProvider { relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { + // uses the internal event to avoid reprocessing cached items. + val note = + if (event is AddressableEvent) { + getOrCreateAddressableNote(event.address()) + } else { + getOrCreateNote(event.id) + } + val wasNew = justConsumeInnerInner(event, relay, wasVerified) if (wasNew) { updateHintIndexes(event) updateMintIndex(event) + updateInGatherer(event, note) } if (relay != null) { - // uses the internal event to avoid reprocessing cached items. - val note = - if (event is AddressableEvent) { - getAddressableNoteIfExists(event.address()) - } else { - getNoteIfExists(event.id) - } - - note?.event?.let { consumedEvent -> + note.event?.let { consumedEvent -> addIncomingRelayAsHintToAllRelatedEvents(consumedEvent, relay) } } @@ -3098,6 +3107,22 @@ object LocalCache : ILocalCache, ICacheProvider { } } + fun updateInGatherer( + event: Event, + note: Note, + ) { + if (event is EventHintProvider) { + event.linkedEventIds().forEach { + checkGetOrCreateNote(it)?.addGatherer(note) + } + } + if (event is AddressHintProvider) { + event.linkedAddressIds().forEach { + checkGetOrCreateAddressableNote(it)?.addGatherer(note) + } + } + } + @Suppress("DEPRECATION") private fun justConsumeInnerInner( event: Event, @@ -3567,6 +3592,22 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is PodcastEpisodeEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is PodcastMetadataEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is AuthoredPodcastsEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is FavoritePodcastsListEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is LnZapEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt index 03e23e2456..f9b93ba58b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip78AppSpecific/AppSpecificState.kt @@ -54,11 +54,11 @@ class AppSpecificState( suspend fun saveNewAppSpecificData(): AppSpecificDataEvent { val toInternal = settings.syncedSettings.toInternal() - return AppSpecificDataEvent.create( - dTag = APP_SPECIFIC_DATA_D_TAG, - description = signer.nip44Encrypt(JsonMapper.toJson(toInternal), signer.pubKey), - otherTags = emptyArray(), - signer = signer, + return signer.sign( + AppSpecificDataEvent.build( + dTag = APP_SPECIFIC_DATA_D_TAG, + description = signer.nip44Encrypt(JsonMapper.toJson(toInternal), signer.pubKey), + ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 02aae3a624..035539b7b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -56,6 +56,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFi import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler @@ -129,6 +131,8 @@ class RelaySubscriptionsCoordinator( val articles = ArticlesFilterAssembler(client) val musicTracks = MusicTracksFilterAssembler(client) val musicPlaylists = MusicPlaylistsFilterAssembler(client) + val podcastEpisodes = PodcastEpisodesFilterAssembler(client) + val podcasts = PodcastsFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) @@ -173,6 +177,8 @@ class RelaySubscriptionsCoordinator( articles, musicTracks, musicPlaylists, + podcastEpisodes, + podcasts, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt index 30818e74d7..94cf6ebdb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/FilterMissingEvents.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders +import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider 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 @@ -66,6 +68,32 @@ fun potentialRelaysToFindEvent(note: Note): Set { childNote.author?.outboxRelays()?.let { set.addAll(it) } } + note.inGatherers?.forEach { parent -> + // loads from parent's relays, parent's authors relays and cited authors in the parent note. + // as well as relays from the channel and other fixed places. + when (parent) { + is Note -> { + set.addAll(parent.relays) + parent.author?.outboxRelays()?.let { set.addAll(it) } + parent.author?.inboxRelays()?.let { set.addAll(it) } + + val noteEvent = parent.event + if (noteEvent is PubKeyHintProvider) { + noteEvent.linkedPubKeys().forEach { potentialAuthor -> + LocalCache.checkGetOrCreateUser(potentialAuthor)?.let { potentialAuthor -> + potentialAuthor.outboxRelays()?.let { set.addAll(it) } + potentialAuthor.inboxRelays()?.let { set.addAll(it) } + } + } + } + } + + is Channel -> { + set.addAll(parent.relays()) + } + } + } + return set } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt index 04dd4f2dfa..52642b7e99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssemblerSubscription.kt @@ -25,8 +25,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider @SuppressLint("StateFlowValueCalledInComposition") @Composable @@ -54,3 +56,33 @@ fun UserFinderFilterAssemblerSubscription( LifecycleAwareKeyDataSourceSubscription(state, dataSource) } + +/** + * Watches all the users that are cited in the parent post. + * So that we load their relay lists and find posts + * cited in this event. + */ +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun UserFinderByParentFilterAssemblerSubscription( + note: Note, + accountViewModel: AccountViewModel, +) { + note.inGatherers?.forEach { parent -> + when (parent) { + is Note -> { + val noteEvent = parent.event + if (noteEvent is PubKeyHintProvider) { + val states = + remember(parent) { + accountViewModel.loadUsersSync(noteEvent.linkedPubKeys()).map { + UserFinderQueryState(it, accountViewModel.account) + } + } + + LifecycleAwareKeyDataSourceSubscription(states, accountViewModel.dataSources().userFinder) + } + } + } + } +} 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 0a02216589..1a6e0be1d5 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 @@ -56,6 +56,8 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val SearchPostsByTextKinds1 = listOf( @@ -69,6 +71,8 @@ val SearchPostsByTextKinds1 = AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, PinListEvent.KIND, ZapPollEvent.KIND, ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 15c288ba0a..c6d8f835d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -77,6 +77,8 @@ object ScrollStateKeys { const val ARTICLES_SCREEN = "ArticlesFeed" const val MUSIC_TRACKS_SCREEN = "MusicTracksFeed" const val MUSIC_PLAYLISTS_SCREEN = "MusicPlaylistsFeed" + const val PODCAST_EPISODES_SCREEN = "PodcastEpisodesFeed" + const val PODCASTS_SCREEN = "PodcastsFeed" const val SEARCH_SCREEN = "SearchFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index 8f27178ddf..ddccd7e96d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -137,6 +137,9 @@ fun StickToTopOnPrepend( stickToTopOnPrepend( stateKey = listState, firstItemKey = firstItemKey, + initialAtTop = { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + }, sampler = { snapshotFlow { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 @@ -156,6 +159,9 @@ fun StickToTopOnPrepend( stickToTopOnPrepend( stateKey = gridState, firstItemKey = firstItemKey, + initialAtTop = { + gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 + }, sampler = { snapshotFlow { gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 @@ -209,6 +215,7 @@ private fun rememberFirstItemIdHex(feedContentState: FeedContentState): String? private fun stickToTopOnPrepend( stateKey: Any, firstItemKey: Any?, + initialAtTop: () -> Boolean, sampler: () -> Flow, isScrollInProgress: () -> Boolean, firstVisibleItemIndex: () -> Int, @@ -216,7 +223,10 @@ private fun stickToTopOnPrepend( ) { // Plain holder instead of mutableStateOf — we only read this inside // effects, never in composition, so we don't need snapshot tracking. - val wasAtTop = remember { booleanArrayOf(true) } + // Seed from the actual restored scroll position: when the user returns + // to a feed via rememberForeverLazyListState, the saved offset is + // already in place, and a hardcoded `true` would mis-snap them to 0. + val wasAtTop = remember(stateKey) { booleanArrayOf(initialAtTop()) } LaunchedEffect(stateKey) { sampler().collect { atTop -> 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 a1fc9f4888..e06be196ac 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 @@ -147,6 +147,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen @@ -293,6 +295,8 @@ fun BuildNavigation( composableFromEnd { ArticlesScreen(accountViewModel, nav) } composableFromEnd { MusicTracksScreen(accountViewModel, nav) } composableFromEnd { MusicPlaylistsScreen(accountViewModel, nav) } + composableFromEnd { PodcastEpisodesScreen(accountViewModel, nav) } + composableFromEnd { PodcastsScreen(accountViewModel, nav) } composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index bfdcc74dd7..01dc584878 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -56,6 +56,8 @@ enum class NavBarItem { SHORTS, MUSIC_TRACKS, MUSIC_PLAYLISTS, + PODCAST_EPISODES, + PODCASTS, PUBLIC_CHATS, FOLLOW_PACKS, LIVE_STREAMS, @@ -246,6 +248,20 @@ val NavBarCatalog: Map = icon = MaterialSymbols.AutoMirrored.PlaylistAdd, resolveRoute = { Route.MusicPlaylists }, ), + NavBarItem.PODCAST_EPISODES to + NavBarItemDef( + id = NavBarItem.PODCAST_EPISODES, + labelRes = R.string.route_podcast_episodes, + icon = MaterialSymbols.Headphones, + resolveRoute = { Route.PodcastEpisodes }, + ), + NavBarItem.PODCASTS to + NavBarItemDef( + id = NavBarItem.PODCASTS, + labelRes = R.string.route_podcasts, + icon = MaterialSymbols.Podcasts, + resolveRoute = { Route.Podcasts }, + ), NavBarItem.PUBLIC_CHATS to NavBarItemDef( id = NavBarItem.PUBLIC_CHATS, @@ -364,6 +380,8 @@ val DrawerFeedsItems: List = NavBarItem.SHORTS, NavBarItem.MUSIC_TRACKS, NavBarItem.MUSIC_PLAYLISTS, + NavBarItem.PODCAST_EPISODES, + NavBarItem.PODCASTS, NavBarItem.PUBLIC_CHATS, NavBarItem.FOLLOW_PACKS, NavBarItem.LIVE_STREAMS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 774d4d228d..b319573c8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -158,6 +158,10 @@ sealed class Route { @Serializable object MusicPlaylists : Route() + @Serializable object PodcastEpisodes : Route() + + @Serializable object Podcasts : Route() + @Serializable data class NewMusicTrack( val dTag: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index d350ee09f6..a10c6225e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -59,7 +59,6 @@ fun BlankNotePreview() { @Composable fun BlankNote( modifier: Modifier = Modifier, - idHex: String? = null, shortPreview: Boolean = false, ) { Column(modifier = modifier) { @@ -81,7 +80,7 @@ fun BlankNote( if (shortPreview) { stringRes(R.string.post_not_found_short) } else { - stringRes(R.string.post_not_found) + if (idHex != null) ": $idHex" else "" + stringRes(R.string.post_not_found) }, modifier = Modifier.padding(30.dp), color = Color.Gray, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt new file mode 100644 index 0000000000..70ecc2dc71 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note + +import android.content.Context +import android.text.format.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Date/time formatters that respect the user's Android system settings: + * - Date order (dd/mm/yyyy, mm/dd/yyyy, yyyy-mm-dd…) is derived from the + * active [Locale] via [DateFormat.getBestDateTimePattern]. + * - 12-hour vs 24-hour clock follows the system setting via + * [DateFormat.is24HourFormat] (which is the user's manual override on + * top of the locale default). + * + * All formatters here use Unicode LDML skeletons. `j` and `jm` are intentionally + * avoided — `j` only picks 12/24 hour from the *locale*, not the user override. + * We pick the time skeleton explicitly based on [DateFormat.is24HourFormat]. + */ + +private fun timeSkeleton(context: Context): String = if (DateFormat.is24HourFormat(context)) "Hm" else "hma" + +private fun bestPattern( + context: Context, + skeletonBase: String, + includeTime: Boolean, +): SimpleDateFormat { + val locale = Locale.getDefault() + val skeleton = if (includeTime) skeletonBase + timeSkeleton(context) else skeletonBase + return SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale) +} + +/** Locale-aware month + day + short time (e.g. "May 28, 14:32" / "28 May, 2:32 PM"). */ +fun formatMonthDayTime( + epochSeconds: Long, + context: Context, +): String = bestPattern(context, "MMMd", includeTime = true).format(Date(epochSeconds * 1000L)) + +/** Locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026"). */ +fun formatMediumDate( + epochSeconds: Long, + context: Context, +): String = DateFormat.getMediumDateFormat(context).format(Date(epochSeconds * 1000L)) + +/** Locale-aware medium date + short time, respecting the system 12/24-hour setting. */ +fun formatMediumDateTime( + epochSeconds: Long, + context: Context, +): String { + val date = DateFormat.getMediumDateFormat(context).format(Date(epochSeconds * 1000L)) + val time = DateFormat.getTimeFormat(context).format(Date(epochSeconds * 1000L)) + return "$date $time" +} 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 34cc57bfa8..e9ebff83ad 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 @@ -156,6 +156,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage @@ -315,6 +317,8 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -930,6 +934,14 @@ private fun RenderNoteRow( RenderMusicPlaylist(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } + is PodcastEpisodeEvent -> { + RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + + is PodcastMetadataEvent -> { + RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is DraftWrapEvent -> { RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index 26e539624a..18ef92c89d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -50,9 +50,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter @Composable fun RelayCompose( @@ -145,9 +142,3 @@ fun RemoveRelayButton(onClick: () -> Unit) { Text(text = stringRes(R.string.remove)) } } - -fun formattedDateTime(timestamp: Long): String = - Instant - .ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a")) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 457d144f44..6e3305263f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -32,25 +32,47 @@ import java.util.Date import java.util.Locale import kotlin.math.round -private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" -private const val MONTH_DATE_FORMAT = "MMM dd" +// Skeletons follow Unicode LDML — DateFormat.getBestDateTimePattern picks the +// correct locale-specific ordering (e.g. "MMM d, y" in en-US vs "d MMM y" in en-GB). +private const val YEAR_SKELETON = "yMMMd" +private const val MONTH_SKELETON = "MMMd" +private const val YEAR_NO_DAY_SKELETON = "yMMM" -private const val YEAR_NO_DAY_DATE_FORMAT = "MMM yyyy" -private const val MONTH_NO_DAY_DATE_FORMAT = "MMM dd" +/** + * Per-thread cached [SimpleDateFormat] keyed off the current default [Locale]. + * + * `SimpleDateFormat` is mutable and not thread-safe, and these formatters are + * read from both the UI thread (composition) and background coroutines + * (e.g. `LocalCache.justVerify` logging failed event verifications). A bare + * `var` shared across threads would race on the formatter's internal Calendar. + * Using `ThreadLocal` gives each thread its own instance — no locks, no + * allocation per call, and we rebuild lazily on locale change. + */ +private class LocaleAwareFormatter( + private val skeleton: String, +) { + private val cache = ThreadLocal>() -var locale: Locale = Locale.getDefault() -var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) -var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + fun get(): SimpleDateFormat { + val current = Locale.getDefault() + val cached = cache.get() + if (cached != null && cached.first == current) return cached.second + val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current) + cache.set(current to fresh) + return fresh + } +} -var yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) -var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) +private val yearFormatter = LocaleAwareFormatter(YEAR_SKELETON) +private val monthFormatter = LocaleAwareFormatter(MONTH_SKELETON) +private val yearNoDayFormatter = LocaleAwareFormatter(YEAR_NO_DAY_SKELETON) /** * Formats a Unix timestamp (seconds) as an absolute date/time string, picking the * granularity from how far away the timestamp is: - * - same day → time only (e.g. "14:32" / "2:32 PM", locale-aware) - * - same year → "MMM dd, HH:mm" - * - older → "MMM dd, yyyy" + * - same day → time only (locale + system 12/24-hr aware via [DateFormat.getTimeFormat]) + * - same year → "Jan 5, 14:32" / "5 Jan 14:32" / "Jan 5, 2:32 PM" (locale + system aware) + * - older → "Jan 5, 2024" / "5 Jan 2024" (locale aware) * * Used by [com.vitorpamplona.amethyst.ui.note.elements.TimeAgo] when the user * taps the relative timestamp to reveal the absolute one. @@ -63,12 +85,6 @@ fun timeAbsolute( if (time == null) return " " if (time == 0L) return prefix + stringRes(context, R.string.never) - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - val timeMs = time * 1000 val now = Calendar.getInstance() val then = Calendar.getInstance().apply { timeInMillis = timeMs } @@ -80,8 +96,8 @@ fun timeAbsolute( return when { sameDay -> prefix + timeOfDay - sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay - else -> prefix + yearFormatter.format(timeMs) + sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay + else -> prefix + yearFormatter.get().format(timeMs) } } @@ -106,26 +122,11 @@ fun timeAgo( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - prefix + yearFormatter.format(time * 1000) + prefix + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - prefix + monthFormatter.format(time * 1000) + prefix + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -157,26 +158,11 @@ fun timeAgoNoDot( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -208,26 +194,11 @@ fun timeAgoNoDotNoDay( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) - monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) - } - - yearNoDayFormatter.format(time * 1000) + yearNoDayFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearNoDayFormatter = SimpleDateFormat(YEAR_NO_DAY_DATE_FORMAT, locale) - monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) - } - - monthNoDayFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -259,26 +230,11 @@ fun timeAheadNoDot( return when { timeDifference > TimeUtils.ONE_YEAR -> { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -310,24 +266,9 @@ fun dateFormatter( val timeDifference = TimeUtils.now() - time return if (timeDifference > TimeUtils.ONE_YEAR) { - // Dec 12, 2022 - - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } else if (timeDifference > TimeUtils.ONE_DAY) { - // Dec 12 - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } else { today } @@ -409,12 +350,7 @@ fun lastSeenSentence( } } - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - } - val dateText = yearFormatter.format(time * 1000) + val dateText = yearFormatter.get().format(time * 1000) return stringRes(context, R.string.last_seen_on_date, dateText, durationText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt index c6d242661d..1a33b4f7a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -51,6 +52,7 @@ fun WatchNoteEvent( accountViewModel = accountViewModel, nav = nav, ) { showPopup -> + UserFinderByParentFilterAssemblerSubscription(baseNote, accountViewModel) BlankNote( remember { modifier.combinedClickable( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt index 7c779d1f36..4bab7562ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.expiration +import android.text.format.DateFormat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -83,15 +84,14 @@ fun ExpirationDatePicker(model: IExpiration) { }, ) + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - Column(Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt index 8e08a138e4..ee67d55a78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.polls import android.annotation.SuppressLint +import android.text.format.DateFormat import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -81,15 +82,14 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) { } }, ) + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, // Set to true if you prefer military time + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - OutlinedCard( onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt index e2c981f9e8..41cb41b86a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/ZapPollDeadlinePicker.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.zappolls import android.annotation.SuppressLint +import android.text.format.DateFormat import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -81,15 +82,14 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) { } }, ) + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, // Set to true if you prefer military time + is24Hour = DateFormat.is24HourFormat(context), ) - val context = LocalContext.current - OutlinedCard( onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt index a5bafcb6af..26579bcf56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -63,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.note.formatMediumDate 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 @@ -76,9 +78,6 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Attes import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Preview @Composable @@ -188,18 +187,19 @@ fun RenderAttestation( } if (validFrom != null || validTo != null) { + val context = LocalContext.current Spacer(modifier = DoubleVertSpacer) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { validFrom?.let { Text( - text = stringRes(R.string.attestation_valid_from, formatTimestamp(it)), + text = stringRes(R.string.attestation_valid_from, formatMediumDate(it, context)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } validTo?.let { Text( - text = stringRes(R.string.attestation_valid_to, formatTimestamp(it)), + text = stringRes(R.string.attestation_valid_to, formatMediumDate(it, context)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -601,8 +601,3 @@ private fun attestationStatusLabel(status: AttestationStatus?): String = status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying) else -> stringRes(R.string.attestation) } - -private fun formatTimestamp(timestamp: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - return sdf.format(Date(timestamp * 1000)) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt new file mode 100644 index 0000000000..9e04361395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +// Top-rounded square cover used by both PodcastEpisode and PodcastMetadata renderers. +// Shape + modifier hoisted to constants so they aren't rebuilt on every recomposition. +private val COVER_IMAGE_SHAPE = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) +private val COVER_IMAGE_MODIFIER = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(COVER_IMAGE_SHAPE) + +@Composable +internal fun PodcastCoverCard( + image: String?, + note: Note, + accountViewModel: AccountViewModel, +) { + Box(COVER_IMAGE_MODIFIER) { + if (image != null) { + MyAsyncImage( + imageUrl = image, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxSize(), + loadedImageModifier = COVER_IMAGE_MODIFIER, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, COVER_IMAGE_MODIFIER) }, + onError = { DefaultImageHeader(note, accountViewModel, COVER_IMAGE_MODIFIER) }, + ) + } else { + DefaultImageHeader(note, accountViewModel, COVER_IMAGE_MODIFIER) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt new file mode 100644 index 0000000000..65c230a3bd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController +import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent + +// NIP-F4 doesn't carry a waveform tag, so every episode renders with the same flat baseline. +// Hoist to a top-level constant so we share one List+WaveformData across the whole +// feed instead of allocating a fresh 96-element list per visible card. +private const val WAVEFORM_SAMPLES = 96 +private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) + +// Bottom-rounded border on the audio player so it visually butts up against the cover's +// top-rounded corners as one card. Constant — keep out of recomposition. +private val PLAYER_BORDER_MODIFIER = + Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) + +@Composable +fun RenderPodcastEpisode( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PodcastEpisodeEvent ?: return + + val title = remember(noteEvent) { noteEvent.title() } + val image = remember(noteEvent) { noteEvent.image() } + val description = remember(noteEvent) { noteEvent.description() } + // Pick the first audio URL. Publishers may emit multiple containers in their preferred + // order; clients with codec preferences can extend this later. + val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() } + // Suppress the markdown block if blank — title + description already describe a short + // episode. Otherwise hand off to RichText below. + val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } } + + Column(MaterialTheme.colorScheme.replyModifier) { + PodcastCoverCard(image, note, accountViewModel) + firstAudio?.let { audio -> + val callbackUri = remember(noteEvent) { note.toNostrUri() } + + Row( + Modifier.fillMaxWidth().height(80.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GetMediaItem( + videoUri = audio.url, + title = title, + artworkUri = image, + authorName = note.author?.toBestDisplayName(), + callbackUri = callbackUri, + mimeType = audio.mediaType, + aspectRatio = null, + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url), + keepPlaying = false, + waveformData = FLAT_WAVEFORM, + ) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + muted = false, + ) { controller -> + PauseControllerWhenInBackground(controller) + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = FLAT_WAVEFORM, + borderModifier = PLAYER_BORDER_MODIFIER, + accountViewModel = accountViewModel, + ) + } + } + } + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + markdown?.takeIf { !makeItShort }?.let { + Spacer(Modifier.padding(top = 4.dp)) + val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } + val callbackUri = remember(noteEvent) { note.toNostrUri() } + + TranslatableRichTextViewer( + content = it, + canPreview = canPreview, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt new file mode 100644 index 0000000000..03e880e5dc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.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.ui.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderPodcastMetadata( + note: Note, + makeItShort: Boolean, + @Suppress("UNUSED_PARAMETER") canPreview: Boolean, + @Suppress("UNUSED_PARAMETER") backgroundColor: MutableState, + accountViewModel: AccountViewModel, + @Suppress("UNUSED_PARAMETER") nav: INav, +) { + val noteEvent = note.event as? PodcastMetadataEvent ?: return + + val title = remember(noteEvent) { noteEvent.title() } + val image = remember(noteEvent) { noteEvent.image() } + val description = remember(noteEvent) { noteEvent.description() } + val websites = remember(noteEvent) { noteEvent.websites() } + + Column(MaterialTheme.colorScheme.replyModifier) { + PodcastCoverCard(image, note, accountViewModel) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + description?.takeIf { !makeItShort }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (websites.isNotEmpty() && !makeItShort) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + websites.forEach { website -> + Text( + text = website, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 340b5417fb..b72c382605 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -59,6 +59,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.dal.PictureFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.PodcastEpisodesFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.PodcastsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.ClosedPollsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.OpenPollsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter @@ -116,6 +118,8 @@ class AccountFeedContentStates( val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache) val musicTracksFeed = FeedContentState(MusicTracksFeedFilter(account), scope, LocalCache) val musicPlaylistsFeed = FeedContentState(MusicPlaylistsFeedFilter(account), scope, LocalCache) + val podcastEpisodesFeed = FeedContentState(PodcastEpisodesFeedFilter(account), scope, LocalCache) + val podcastsFeed = FeedContentState(PodcastsFeedFilter(account), scope, LocalCache) val softwareAppsFeed = FeedContentState(SoftwareAppsFeedFilter(account), scope, LocalCache) val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) @@ -207,6 +211,8 @@ class AccountFeedContentStates( articlesFeed.updateFeedWith(newNotes) musicTracksFeed.updateFeedWith(newNotes) musicPlaylistsFeed.updateFeedWith(newNotes) + podcastEpisodesFeed.updateFeedWith(newNotes) + podcastsFeed.updateFeedWith(newNotes) softwareAppsFeed.updateFeedWith(newNotes) calendarAppointmentsFeed.updateFeedWith(newNotes) @@ -265,6 +271,8 @@ class AccountFeedContentStates( articlesFeed.deleteFromFeed(newNotes) musicTracksFeed.deleteFromFeed(newNotes) musicPlaylistsFeed.deleteFromFeed(newNotes) + podcastEpisodesFeed.deleteFromFeed(newNotes) + podcastsFeed.deleteFromFeed(newNotes) softwareAppsFeed.deleteFromFeed(newNotes) calendarAppointmentsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 2336e72399..02063940a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilte import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssemblerSubscription @@ -102,6 +104,10 @@ private fun PreloadFor( // tabs no matter which one the user pinned. -> MusicTracksFilterAssemblerSubscription(accountViewModel) + NavBarItem.PODCAST_EPISODES -> PodcastEpisodesFilterAssemblerSubscription(accountViewModel) + + NavBarItem.PODCASTS -> PodcastsFilterAssemblerSubscription(accountViewModel) + NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel) NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt index a937a04db2..6cd1e7fa29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt @@ -46,6 +46,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -164,6 +165,7 @@ private fun DayRow( } val startSeconds = view.startSeconds + val context = LocalContext.current val timeLabel = when { view.isAllDay -> stringRes(R.string.calendar_all_day) @@ -172,7 +174,7 @@ private fun DayRow( // misleading on day 2 since the event has been ongoing overnight. Show a // continuation marker so the user reads it as "still happening". stringRes(R.string.calendar_continues) - startSeconds != null -> formatTimeOfDay(startSeconds) + startSeconds != null -> formatTimeOfDay(startSeconds, context) else -> "—" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt index e3f0599e8e..8af8a0269e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -72,8 +72,8 @@ fun CalendarEventListCard( modifier: Modifier = Modifier, ) { val view = note.appointmentView() ?: return - val range = remember(note.idHex) { formatCalendarRange(note) } val context = LocalContext.current + val range = remember(note.idHex) { formatCalendarRange(note, context) } val relative = remember(note.idHex, view.startSeconds) { relativeTimeLabel(context, view, TimeUtils.now()) } val event = note.event ?: return val detailRoute = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt index f24a7cd20a..2738041aa9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars +import android.content.Context +import android.text.format.DateFormat import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.model.Note import java.text.SimpleDateFormat @@ -27,34 +29,73 @@ import java.util.Calendar import java.util.Date import java.util.Locale -private val DayMonthFormat = SimpleDateFormat("EEE, MMM d", Locale.getDefault()) -private val FullDateFormat = SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault()) -private val MonthYearFormat = SimpleDateFormat("MMMM yyyy", Locale.getDefault()) -private val TimeFormat = SimpleDateFormat("h:mm a", Locale.getDefault()) -private val WeekdayShortFormat = SimpleDateFormat("EEE", Locale.getDefault()) +// Skeletons follow Unicode LDML — getBestDateTimePattern picks locale ordering. +private const val DAY_MONTH_SKELETON = "EEEMMMd" // "Mon, May 28" / "Mon 28 May" +private const val FULL_DATE_SKELETON = "EEEEMMMMdy" // "Monday, May 28, 2026" / "Monday, 28 May 2026" +private const val MONTH_YEAR_SKELETON = "MMMMy" // "May 2026" / "Mai 2026" +private const val WEEKDAY_SHORT_SKELETON = "EEE" // "Mon" — order doesn't matter -fun formatCalendarRange(note: Note): String? { +/** + * Per-thread cached [SimpleDateFormat] keyed off the current default [Locale]. + * + * Cached because these run per cell in the calendar grid (≈42 cells per month, + * 7 per weekday header) — allocating a fresh SimpleDateFormat each call + * showed up as scroll-time GC churn. Per-thread because SimpleDateFormat + * isn't thread-safe; calendars today are read on the UI thread but future + * callers could differ. ThreadLocal gives both — no locks, no contention, + * lazy rebuild on locale change. + */ +private class LocaleAwareFormatter( + private val skeleton: String, +) { + private val cache = ThreadLocal>() + + fun get(): SimpleDateFormat { + val current = Locale.getDefault() + val cached = cache.get() + if (cached != null && cached.first == current) return cached.second + val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current) + cache.set(current to fresh) + return fresh + } +} + +private val dayMonthFormat = LocaleAwareFormatter(DAY_MONTH_SKELETON) +private val fullDateFormat = LocaleAwareFormatter(FULL_DATE_SKELETON) +private val monthYearFormat = LocaleAwareFormatter(MONTH_YEAR_SKELETON) +private val weekdayShortFormat = LocaleAwareFormatter(WEEKDAY_SHORT_SKELETON) + +// Time format respects the user's Android 12/24-hour system setting. +private fun timeFormat(context: Context): java.text.DateFormat = DateFormat.getTimeFormat(context) + +fun formatCalendarRange( + note: Note, + context: Context, +): String? { val view = note.appointmentView() ?: return null val start = view.startSeconds ?: return null return if (view.isAllDay) { formatDateRange(start, view.endSeconds) } else { - formatTimeRange(start, view.endSeconds) + formatTimeRange(start, view.endSeconds, context) } } private fun formatTimeRange( start: Long, end: Long?, + context: Context, ): String { + val dayMonth = dayMonthFormat.get() + val time = timeFormat(context) val startMs = start * 1000 - val startStr = "${DayMonthFormat.format(Date(startMs))} · ${TimeFormat.format(Date(startMs))}" + val startStr = "${dayMonth.format(Date(startMs))} · ${time.format(Date(startMs))}" if (end == null || end == start) return startStr val endMs = end * 1000 return if (isSameDay(startMs, endMs)) { - "$startStr – ${TimeFormat.format(Date(endMs))}" + "$startStr – ${time.format(Date(endMs))}" } else { - "$startStr – ${DayMonthFormat.format(Date(endMs))} · ${TimeFormat.format(Date(endMs))}" + "$startStr – ${dayMonth.format(Date(endMs))} · ${time.format(Date(endMs))}" } } @@ -62,12 +103,13 @@ private fun formatDateRange( start: Long, end: Long?, ): String { - val startStr = DayMonthFormat.format(Date(start * 1000)) + val dayMonth = dayMonthFormat.get() + val startStr = dayMonth.format(Date(start * 1000)) if (end == null || end == start) return startStr - return "$startStr – ${DayMonthFormat.format(Date(end * 1000))}" + return "$startStr – ${dayMonth.format(Date(end * 1000))}" } -fun formatLongDate(unixSeconds: Long): String = FullDateFormat.format(Date(unixSeconds * 1000)) +fun formatLongDate(unixSeconds: Long): String = fullDateFormat.get().format(Date(unixSeconds * 1000)) fun formatMonthYear( year: Int, @@ -76,10 +118,13 @@ fun formatMonthYear( val cal = Calendar.getInstance() cal.clear() cal.set(year, monthZeroBased, 1) - return MonthYearFormat.format(cal.time) + return monthYearFormat.get().format(cal.time) } -fun formatTimeOfDay(unixSeconds: Long): String = TimeFormat.format(Date(unixSeconds * 1000)) +fun formatTimeOfDay( + unixSeconds: Long, + context: Context, +): String = timeFormat(context).format(Date(unixSeconds * 1000)) fun formatShortWeekday(weekdayZeroBased: Int): String { val cal = Calendar.getInstance() @@ -87,7 +132,7 @@ fun formatShortWeekday(weekdayZeroBased: Int): String { cal.firstDayOfWeek = Calendar.SUNDAY cal.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY) cal.add(Calendar.DAY_OF_YEAR, weekdayZeroBased) - return WeekdayShortFormat.format(cal.time) + return weekdayShortFormat.get().format(cal.time) } private fun isSameDay( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt index c9c6f48cde..b90bac387e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/CalendarDateTimePickerButton.kt @@ -38,14 +38,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import java.text.DateFormat -import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset import java.util.Date +import android.text.format.DateFormat as AndroidDateFormat /** * Tap-to-edit button that opens a Material3 DatePicker (and, when [includeTime] is true, @@ -68,6 +69,7 @@ fun CalendarDateTimePickerButton( var showTime by remember { mutableStateOf(false) } val locale = LocalConfiguration.current.locales[0] + val context = LocalContext.current val pretty = if (unixSeconds <= 0L) { placeholder @@ -76,7 +78,7 @@ fun CalendarDateTimePickerButton( .getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, locale) .format(Date(unixSeconds * 1000)) } else { - SimpleDateFormat("EEEE, MMMM d, yyyy", locale).format(Date(unixSeconds * 1000)) + DateFormat.getDateInstance(DateFormat.FULL, locale).format(Date(unixSeconds * 1000)) } val initialMillis = if (unixSeconds > 0L) unixSeconds * 1000L else System.currentTimeMillis() @@ -94,7 +96,7 @@ fun CalendarDateTimePickerButton( rememberTimePickerState( initialHour = initialLocal.hour, initialMinute = initialLocal.minute, - is24Hour = false, + is24Hour = AndroidDateFormat.is24HourFormat(context), ) fun reset() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index e7de2dd865..7d68d70882 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -318,14 +318,14 @@ private fun EventBody( fontWeight = FontWeight.Bold, ) } - formatCalendarRange(note)?.let { range -> + val context = LocalContext.current + formatCalendarRange(note, context)?.let { range -> Text( text = range, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary, ) } - val context = LocalContext.current val relative = remember(note.idHex, view.startSeconds) { relativeTimeLabel(context, view, TimeUtils.now()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index e9b59f6c10..480757da65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -297,7 +297,13 @@ open class ChannelNewMessageViewModel : val version = draftTag.current cancel() - accountViewModel.account.signAndSendPrivately(template, channelRelays) + if (channel is LiveActivitiesChannel) { + accountViewModel.account.signAndSendPrivatelyOrBroadcast(template) { + channelRelays.toList() + } + } else { + accountViewModel.account.signAndSendPrivately(template, channelRelays) + } accountViewModel.viewModelScope.launch(Dispatchers.IO) { accountViewModel.account.deleteDraftIgnoreErrors(version) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index dde17014f3..e7022890db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout @@ -96,6 +97,7 @@ fun ChatroomHeaderCompose( if (hasEvent) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { + UserFinderByParentFilterAssemblerSubscription(baseNote, accountViewModel) BlankNote() } } @@ -167,6 +169,7 @@ private fun ChatroomEntry( } else -> { + UserFinderByParentFilterAssemblerSubscription(lastMessage, accountViewModel) BlankNote() } } 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 49866f79ba..e30adf0f71 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 @@ -47,6 +47,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class FollowPackFeedNewThreadFeedFilter( val followPackNote: AddressableNote, @@ -58,6 +60,7 @@ class FollowPackFeedNewThreadFeedFilter( AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastMetadataEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, @@ -141,6 +144,8 @@ class FollowPackFeedNewThreadFeedFilter( noteEvent is AudioTrackEvent || noteEvent is MusicTrackEvent || noteEvent is MusicPlaylistEvent || + noteEvent is PodcastEpisodeEvent || + noteEvent is PodcastMetadataEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent ) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index f2b7319928..52f726d396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class GeoHashFeedFilter( val tag: String, @@ -82,7 +84,9 @@ class GeoHashFeedFilter( event is ZapPollEvent || event is AudioHeaderEvent || event is MusicTrackEvent || - event is MusicPlaylistEvent + event is MusicPlaylistEvent || + event is PodcastEpisodeEvent || + event is PodcastMetadataEvent ) && event.isTaggedGeoHash(geohash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 257dba9cc7..0fa9ec6fc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -38,6 +38,8 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val PostsByGeohashKinds = listOf( @@ -52,6 +54,8 @@ val PostsByGeohashKinds = AudioHeaderEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, WikiNoteEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index daf3a81470..a3f47452e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -41,6 +41,8 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class HashtagFeedFilter( val tag: String, @@ -86,7 +88,9 @@ class HashtagFeedFilter( event is ZapPollEvent || event is AudioHeaderEvent || event is MusicTrackEvent || - event is MusicPlaylistEvent + event is MusicPlaylistEvent || + event is PodcastEpisodeEvent || + event is PodcastMetadataEvent ) && event.isTaggedHash(hashTag) 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 ca2b17014b..4941239783 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 @@ -43,6 +43,8 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val PostsByHashtagsKinds = listOf( @@ -64,6 +66,8 @@ val PostsByHashtagKinds2 = AudioHeaderEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 1e7f0dba26..0499722ff1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -51,6 +51,8 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class HomeNewThreadFeedFilter( val account: Account, @@ -61,6 +63,7 @@ class HomeNewThreadFeedFilter( AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastMetadataEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, ClassifiedsEvent.KIND, @@ -131,6 +134,8 @@ class HomeNewThreadFeedFilter( noteEvent is AudioTrackEvent || noteEvent is MusicTrackEvent || noteEvent is MusicPlaylistEvent || + noteEvent is PodcastEpisodeEvent || + noteEvent is PodcastMetadataEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent || noteEvent is ChessGameEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt index 35df6bc622..532d72d777 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBri import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.launch +import android.text.format.DateFormat as AndroidDateFormat /** * Bottom sheet that lets the logged-in user start a new NIP-53 kind 30312 @@ -248,6 +249,8 @@ private fun ScheduleStartPicker( var showDate by remember { mutableStateOf(false) } var showTime by remember { mutableStateOf(false) } + val context = LocalContext.current + val pretty = if (unixSeconds <= 0L) { stringRes(R.string.nest_create_when) @@ -277,7 +280,7 @@ private fun ScheduleStartPicker( androidx.compose.material3.rememberTimePickerState( initialHour = initialLocal.hour, initialMinute = initialLocal.minute, - is24Hour = false, + is24Hour = AndroidDateFormat.is24HourFormat(context), ) // On dismiss (Cancel or back-press), restore the picker states 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 9ef5cafd75..4531066f1b 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 @@ -79,6 +79,8 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -99,6 +101,8 @@ class NotificationFeedFilter( AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, CalendarRSVPEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt new file mode 100644 index 0000000000..ce36765f75 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +// One shared body for both Episodes (kind 54) and Shows (kind 10154) — NoteCompose's +// renderer dispatch picks the right card per item, the list scaffolding is identical. +@Composable +internal fun PodcastFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + NoteCompose( + baseNote = item, + modifier = Modifier, + isBoostedNote = false, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider(thickness = DividerThickness) + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +fun PodcastEpisodesFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) = PodcastFeedLoaded(loaded, listState, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt new file mode 100644 index 0000000000..940282b58f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesScreen.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastEpisodesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + PodcastEpisodesScreen( + episodesFeedContentState = accountViewModel.feedStates.podcastEpisodesFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun PodcastEpisodesScreen( + episodesFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(episodesFeedContentState) + WatchAccountForPodcastEpisodesScreen( + episodesFeedState = episodesFeedContentState, + accountViewModel = accountViewModel, + ) + // Drives the kind:54 REQ for the user's selected follow list — without it the screen + // only shows episodes already in cache from some other path (e.g. a nostr: link). + PodcastEpisodesFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + PodcastEpisodesTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.PodcastEpisodes, nav, accountViewModel) { route -> + if (route == Route.PodcastEpisodes) { + episodesFeedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(episodesFeedContentState, true) { + SaveableFeedContentState(episodesFeedContentState, scrollStateKey = ScrollStateKeys.PODCAST_EPISODES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = episodesFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "PodcastEpisodesFeed", + onLoaded = { loaded -> + PodcastEpisodesFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForPodcastEpisodesScreen( + episodesFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.livePodcastEpisodesFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + episodesFeedState.checkKeysInvalidateDataAndSendToTop() + } +} + +@Preview +@Composable +private fun PodcastEpisodesScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + PodcastEpisodesScreen( + episodesFeedContentState = accountViewModel.feedStates.podcastEpisodesFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt new file mode 100644 index 0000000000..ca40ecb546 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastEpisodesTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultPodcastEpisodesFollowList + .collectAsStateWithLifecycle() + + PodcastEpisodesTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultPodcastEpisodesFollowList, + ) + } +} + +@Composable +private fun PodcastEpisodesTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + // Same content-style catalog as Music/Articles — All Follows, Your Follows, kind3 + // Follows, Around Me, Global, people lists, interest sets, mute list. + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = onChange, + accountViewModel = accountViewModel, + ) +} + +@Preview +@Composable +private fun PodcastEpisodesTopBarPreview() { + ThemeComparisonColumn { + PodcastEpisodesTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt new file mode 100644 index 0000000000..2827387975 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +// Episodes (kind 54) and Shows (kind 10154) feeds render identically — each note flows +// through NoteCompose's renderer dispatch. Keeping one shared composable means a divider +// or padding tweak ships to both screens at once. +@Composable +fun PodcastsFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) = PodcastFeedLoaded(loaded, listState, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt new file mode 100644 index 0000000000..8762972787 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + PodcastsScreen( + podcastsFeedContentState = accountViewModel.feedStates.podcastsFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun PodcastsScreen( + podcastsFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(podcastsFeedContentState) + WatchAccountForPodcastsScreen( + podcastsFeedState = podcastsFeedContentState, + accountViewModel = accountViewModel, + ) + // Dedicated REQ for kind 10154 only — episode events come down through the episodes + // screen's own subscription, keeping each screen's `since` cursor isolated. + PodcastsFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + PodcastsTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.Podcasts, nav, accountViewModel) { route -> + if (route == Route.Podcasts) { + podcastsFeedContentState.sendToTop() + } else { + nav.navBottomBar(route) + } + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(podcastsFeedContentState, true) { + SaveableFeedContentState(podcastsFeedContentState, scrollStateKey = ScrollStateKeys.PODCASTS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = podcastsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "PodcastsFeed", + onLoaded = { loaded -> + PodcastsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForPodcastsScreen( + podcastsFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.livePodcastsFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + podcastsFeedState.checkKeysInvalidateDataAndSendToTop() + } +} + +@Preview +@Composable +private fun PodcastsScreenPreview() { + val accountViewModel = mockAccountViewModel() + ThemeComparisonColumn { + PodcastsScreen( + podcastsFeedContentState = accountViewModel.feedStates.podcastsFeed, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt new file mode 100644 index 0000000000..28dcbeb002 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +fun PodcastsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultPodcastsFollowList + .collectAsStateWithLifecycle() + + PodcastsTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultPodcastsFollowList, + ) + } +} + +@Composable +private fun PodcastsTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = onChange, + accountViewModel = accountViewModel, + ) +} + +@Preview +@Composable +private fun PodcastsTopBarPreview() { + ThemeComparisonColumn { + PodcastsTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt new file mode 100644 index 0000000000..2a41a0e13a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet +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.nipF4Podcasts.episode.PodcastEpisodeEvent + +/** + * Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike + * music tracks/playlists which are addressable. Same follow-list + hidden/blocked + * gate as MusicTracksFeedFilter. + */ +class PodcastEpisodesFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "podcast-episodes-" + account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultPodcastEpisodesFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.notes.filterIntoSet { _, it -> + accept(it, params) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.livePodcastEpisodesFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + return collection.filterTo(HashSet()) { accept(it, params) } + } + + private fun accept( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + return noteEvent is PodcastEpisodeEvent && + params.match(noteEvent, note.relays) && + (params.isHiddenList || account.isAcceptable(note)) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt new file mode 100644 index 0000000000..742951b511 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet +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.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** + * Show-level podcast metadata (kind 10154) is a replaceable event, so it lives in + * `LocalCache.addressables` — same shape as the music-playlists filter. + */ +class PodcastsFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "podcasts-" + account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultPodcastsFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it -> + accept(it, params) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.livePodcastsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + return collection.filterTo(HashSet()) { accept(it, params) } + } + + private fun accept( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + return noteEvent is PodcastMetadataEvent && + params.match(noteEvent, note.relays) && + (params.isHiddenList || account.isAcceptable(note)) + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt new file mode 100644 index 0000000000..efb9bdb167 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class PodcastEpisodesQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class PodcastEpisodesFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + private val episodes = PodcastEpisodesSubAssembler(client, ::allKeys) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = episodes.invalidateFilters() + + override fun destroy() = episodes.destroy() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..5d0ab8998c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun PodcastEpisodesFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + PodcastEpisodesFilterAssemblerSubscription( + accountViewModel.dataSources().podcastEpisodes, + accountViewModel, + ) +} + +@Composable +fun PodcastEpisodesFilterAssemblerSubscription( + dataSource: PodcastEpisodesFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + PodcastEpisodesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt new file mode 100644 index 0000000000..87eeb1a3ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesSubAssembler.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class PodcastEpisodesSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: PodcastEpisodesQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makePodcastEpisodesFilter(feedSettings, since, key.feedStates.podcastEpisodesFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: PodcastEpisodesQueryState) = key.account.userProfile() + + override fun list(key: PodcastEpisodesQueryState) = key.listName() + + fun PodcastEpisodesQueryState.listNameFlow() = account.settings.defaultPodcastEpisodesFollowList + + fun PodcastEpisodesQueryState.listName() = listNameFlow().value + + fun PodcastEpisodesQueryState.followsPerRelayFlow() = account.livePodcastEpisodesFollowListsPerRelay + + fun PodcastEpisodesQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: PodcastEpisodesQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.podcastEpisodesFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt new file mode 100644 index 0000000000..85d6e8c995 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class PodcastsQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class PodcastsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + private val podcasts = PodcastsSubAssembler(client, ::allKeys) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = podcasts.invalidateFilters() + + override fun destroy() = podcasts.destroy() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..4b8c44ab92 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun PodcastsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + PodcastsFilterAssemblerSubscription( + accountViewModel.dataSources().podcasts, + accountViewModel, + ) +} + +@Composable +fun PodcastsFilterAssemblerSubscription( + dataSource: PodcastsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + PodcastsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt new file mode 100644 index 0000000000..579e4cae88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsSubAssembler.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class PodcastsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: PodcastsQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makePodcastsFilter(feedSettings, since, key.feedStates.podcastsFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: PodcastsQueryState) = key.account.userProfile() + + override fun list(key: PodcastsQueryState) = key.listName() + + fun PodcastsQueryState.listNameFlow() = account.settings.defaultPodcastsFollowList + + fun PodcastsQueryState.listName() = listNameFlow().value + + fun PodcastsQueryState.followsPerRelayFlow() = account.livePodcastsFollowListsPerRelay + + fun PodcastsQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: PodcastsQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.podcastsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt new file mode 100644 index 0000000000..19b65c62aa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAllCommunities +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByGeohashes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByMutedAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makePodcastEpisodesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = makePodcastFilter(feedSettings, PODCAST_EPISODE_KINDS, since, defaultSince) + +fun makePodcastsFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) + +private fun makePodcastFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long?, +): List = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt new file mode 100644 index 0000000000..4a56858280 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +// Each podcast screen issues its own REQ keyed on its own follow-list selector — keep +// the kind lists split so episodes and show metadata never co-mingle in one filter. +internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND) +internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND) + +// NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is +// its own keypair, so podcast pubkeys generally aren't in the user's kind:3 contact list — +// they live in kind:10054 (FavoritePodcastsListEvent) and kind:10064 (AuthoredPodcastsEvent). +// With the current resolution the "Following" Podcasts tab returns content only if the user +// also kind:3-follows a podcast key. A future change should plumb a TopFilter.PodcastFavorites +// selector through the topNav state and resolve authors via the user's own 10054 list. + +fun filterPodcastEventsByAuthors( + relay: NormalizedRelayUrl, + kinds: List, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = kinds, + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPodcastEventsByAuthors( + relay = it.key, + kinds = kinds, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPodcastEventsByMutedAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPodcastEventsByAuthors( + relay = it.key, + kinds = kinds, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt new file mode 100644 index 0000000000..beb0d05142 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent + +fun filterPodcastEventsFromAllCommunities( + relay: NormalizedRelayUrl, + kinds: List, + communities: Set, + since: Long? = null, +): List { + val communityList = communities.sorted() + val kindsAsStrings = kinds.map { it.toString() } + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to communityList, + "k" to kindsAsStrings, + ), + limit = communityList.size * 20, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("a" to communityList), + limit = communityList.size * 20, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByAllCommunities( + communitySet: AllCommunitiesTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set.flatMap { + filterPodcastEventsFromAllCommunities( + relay = it.key, + kinds = kinds, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } +} + +fun filterPodcastEventsFromCommunity( + relay: NormalizedRelayUrl, + kinds: List, + community: String, + authors: Set?, + since: Long? = null, +): List { + val authorList = authors?.sorted() + val kindsAsStrings = kinds.map { it.toString() } + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = CommunityPostApprovalEvent.KIND_LIST, + tags = + mapOf( + "a" to listOf(community), + "k" to kindsAsStrings, + ), + limit = 100, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = kinds, + tags = mapOf("a" to listOf(community)), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByCommunity( + communitySet: SingleCommunityTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set.flatMap { + filterPodcastEventsFromCommunity( + relay = it.key, + kinds = kinds, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt new file mode 100644 index 0000000000..0b573739d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterPodcastEventsByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val sinceForRelay = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { authors -> + filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt new file mode 100644 index 0000000000..f746865b0d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterPodcastEventsByGeohashes( + relay: NormalizedRelayUrl, + kinds: List, + geotags: Set, + since: Long?, +): List { + if (geotags.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("g" to geotags.sorted()), + limit = 100, + since = since, + ), + ), + ) +} + +fun filterPodcastEventsByGeohashes( + geoSet: LocationTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long?, +): List { + if (geoSet.set.isEmpty()) return emptyList() + + return geoSet.set + .mapNotNull { + if (it.value.geotags.isEmpty()) { + null + } else { + filterPodcastEventsByGeohashes( + relay = it.key, + kinds = kinds, + geotags = it.value.geotags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt new file mode 100644 index 0000000000..124c464a17 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +fun filterPodcastEventsByHashtag( + relay: NormalizedRelayUrl, + kinds: List, + hashtags: Set, + since: Long? = null, +): List = + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = kinds, + tags = mapOf("t" to hashtags.sorted()), + limit = 200, + since = since, + ), + ), + ) + +fun filterPodcastEventsByHashtag( + hashtagSet: HashtagTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { + if (it.value.hashtags.isEmpty()) { + null + } else { + filterPodcastEventsByHashtag( + relay = it.key, + kinds = kinds, + hashtags = it.value.hashtags, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt new file mode 100644 index 0000000000..e0fc0c4cba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +fun filterPodcastEventsGlobal( + relays: GlobalTopNavPerRelayFilterSet, + kinds: List, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + // Podcasts are sparse on most relays today, so no `oneWeekAgo` floor — let the relay's + // own limit + kind filter bound the response. Mirrors the music-global rationale. + val sinceForRelay = since?.get(it.key)?.time ?: defaultSince + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = kinds, + limit = 200, + since = sinceForRelay, + ), + ) + } +} 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 683a74efb4..5d20c01627 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 @@ -45,6 +45,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class UserProfileMutualFeedFilter( val user: User, @@ -86,6 +88,8 @@ class UserProfileMutualFeedFilter( it.event is AudioTrackEvent || it.event is MusicTrackEvent || it.event is MusicPlaylistEvent || + it.event is PodcastEpisodeEvent || + it.event is PodcastMetadataEvent || it.event is AudioHeaderEvent || it.event is PublicMessageEvent || it.event is TorrentEvent 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 d1e5ce0d4d..6b49c59c06 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 @@ -50,6 +50,8 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class UserProfileNewThreadFeedFilter( val user: User, @@ -93,6 +95,8 @@ class UserProfileNewThreadFeedFilter( it.event is AudioTrackEvent || it.event is MusicTrackEvent || it.event is MusicPlaylistEvent || + it.event is PodcastEpisodeEvent || + it.event is PodcastMetadataEvent || it.event is AudioHeaderEvent || it.event is VoiceEvent || it.event is TorrentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt index 69fb17f108..9174f45a76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt @@ -40,6 +40,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val PostsByRelayKinds = listOf( @@ -61,6 +63,8 @@ val PostsByRelayKinds2 = AudioHeaderEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) 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 310df52ed6..4e2c151cd3 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 @@ -261,6 +261,10 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -540,6 +544,10 @@ fun kindDisplayName(kind: Int): Int = AudioTrackEvent.KIND -> R.string.kind_audio_track MusicTrackEvent.KIND -> R.string.kind_music_track MusicPlaylistEvent.KIND -> R.string.kind_music_playlist + PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode + PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata + AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts + FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts AttestationEvent.KIND -> R.string.attestation AttestationRequestEvent.KIND -> R.string.attestation_request AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt index a83dea75f5..6971c82f94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncScreen.kt @@ -69,15 +69,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.formatMediumDate import com.vitorpamplona.amethyst.ui.note.timeAgoNoDotNoDay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun EventSyncScreen( @@ -750,6 +748,8 @@ private fun DateRangeFilterCard( LaunchedEffect(effectiveSince) { onSinceChanged(effectiveSince) } LaunchedEffect(effectiveUntil) { onUntilChanged(effectiveUntil) } + val context = LocalContext.current + Card( modifier = Modifier.fillMaxWidth(), @@ -775,7 +775,7 @@ private fun DateRangeFilterCard( if (effectiveSince == null) { stringRes(R.string.event_sync_date_filter_all_time) } else { - stringRes(R.string.event_sync_date_filter_since) + " " + formatEpochDate(sinceEpoch) + stringRes(R.string.event_sync_date_filter_since) + " " + formatMediumDate(sinceEpoch, context) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, @@ -785,7 +785,7 @@ private fun DateRangeFilterCard( if (effectiveUntil == null) { stringRes(R.string.event_sync_date_filter_now) } else { - stringRes(R.string.event_sync_date_filter_until) + " " + formatEpochDate(untilEpoch) + stringRes(R.string.event_sync_date_filter_until) + " " + formatMediumDate(untilEpoch, context) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, @@ -810,11 +810,6 @@ private fun DateRangeFilterCard( } } -private fun formatEpochDate(epochSecs: Long): String { - val sdf = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) - return sdf.format(Date(epochSecs * 1000)) -} - // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt index c63a49f64c..7adc705e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish +import android.text.format.DateFormat import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -58,6 +59,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -72,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog @@ -85,12 +88,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.toImmutableList -import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset -import java.util.Date -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -131,11 +131,12 @@ fun RequestToVanishScreen( val currentTime = Instant.ofEpochMilli(vanishDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + val context = LocalContext.current val timePickerState = rememberTimePickerState( initialHour = currentTime.hour, initialMinute = currentTime.minute, - is24Hour = false, + is24Hour = DateFormat.is24HourFormat(context), ) val relayOptions = @@ -288,7 +289,7 @@ fun RequestToVanishScreen( ) Spacer(Modifier.width(12.dp)) Text( - text = formatTimestamp(vanishDate), + text = formatMediumDateTime(vanishDate, context), style = MaterialTheme.typography.bodyLarge, ) } @@ -461,11 +462,6 @@ private fun ConfirmVanishDialog( ) } -private fun formatTimestamp(epochSeconds: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) - return sdf.format(Date(epochSeconds * 1000)) -} - @Preview @Composable fun RequestToVanishScreenPreview() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt index 69f9549c7d..6909ecb491 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/VanishEventsScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,15 +59,13 @@ import com.vitorpamplona.amethyst.model.nip62Vanish.ComplianceStatus import com.vitorpamplona.amethyst.model.nip62Vanish.VanishEventItem import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun VanishEventsScreen( @@ -170,6 +169,7 @@ private fun VanishEventCard( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, ), ) { + val context = LocalContext.current Column(modifier = Modifier.padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -182,7 +182,7 @@ private fun VanishEventCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = formatTimestamp(item.event.createdAt), + text = formatMediumDateTime(item.event.createdAt, context), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, ) @@ -355,8 +355,3 @@ private fun RelayComplianceRow( } } } - -private fun formatTimestamp(epochSeconds: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault()) - return sdf.format(Date(epochSeconds * 1000)) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index cf49f27d32..0d3f1d9f27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -60,6 +60,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings +import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend @@ -79,8 +81,6 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConf import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.RpcProbeResult import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date import java.util.Locale /** @@ -497,10 +497,10 @@ private fun DiagnosticCard( // Last test timestamp if (lastTestTimestamp != null) { + val context = LocalContext.current val formatted = - remember(lastTestTimestamp) { - SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - .format(Date(lastTestTimestamp)) + remember(lastTestTimestamp, context) { + formatMediumDateTime(lastTestTimestamp / 1000L, context) } val successCount = testResults.count { it.success } val totalCount = testResults.size 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 a5c7ecc0b5..244bf222ff 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 @@ -173,6 +173,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage @@ -308,6 +310,8 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -646,6 +650,10 @@ private fun FullBleedNoteCompose( RenderMusicTrack(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is MusicPlaylistEvent) { RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PodcastEpisodeEvent) { + RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PodcastMetadataEvent) { + RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt index 72181d1936..59f45d0802 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.font.FontFamily @@ -65,15 +66,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime 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.wallet.datasource.OnchainZapsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import kotlin.math.absoluteValue @OptIn(ExperimentalMaterial3Api::class) @@ -299,14 +298,11 @@ private fun OnchainTransactionItem( (if (isIncoming) "+" else "-") + fmt.format(amountSats) } + val context = LocalContext.current val dateText = - remember(view.tx.blockTime, view.tx.confirmations) { + remember(view.tx.blockTime, view.tx.confirmations, context) { val ts = view.tx.blockTime - if (ts != null) { - SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()).format(Date(ts * 1000L)) - } else { - "" - } + if (ts != null) formatMonthDayTime(ts, context) else "" } val counterpartyPubkeyHex = view.counterpartyPubkeyHex() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index d662e3c6d4..4e0b8be67d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -60,15 +61,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -238,12 +237,10 @@ private fun TransactionItem( (if (isIncoming) "+" else "-") + fmt.format(amountSats) } + val context = LocalContext.current val dateText = - remember(tx.created_at) { - tx.created_at?.let { - val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) - sdf.format(Date(it * 1000L)) - } ?: "" + remember(tx.created_at, context) { + tx.created_at?.let { formatMonthDayTime(it, context) } ?: "" } val parsed = remember(tx.metadata) { tx.parsedMetadata() } diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 736d7190f0..28a891b10e 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -745,6 +745,36 @@ Zatím žádné servery. Přidejte server níže nebo vyberte z doporučených. Videa Články + Hudba + Playlisty + Nová hudební stopa + Nový playlist + Název + Název stopy + Interpret + Jméno interpreta + URL zvuku + https://… .mp3 + URL obrázku obalu (volitelné) + Album (volitelné) + Délka v sekundách (volitelné) + Text nebo kredity (volitelné) + Smazat tuto stopu + Publikovat NIP-09 smazání této hudební stopy? + Přidat do playlistu + Přidat do playlistu + Zatím nemáte žádné playlisty. Pomocí pole výše si nějaký vytvořte. + Spravovat všechny playlisty + Nový playlist + Název playlistu + Vytvořit + (playlist bez názvu) + + %1$d stopa + %1$d stopy + %1$d stopy + %1$d stop + Soukromé záložky Veřejné záložky Přidat do soukromých záložek @@ -912,6 +942,7 @@ Adresa Smazat platební cíl Nebyla nalezena žádná aplikace pro zpracování této platby. Nainstalujte prosím kompatibilní peněženku. + Není nainstalována žádná aplikace pro platby typu %1$s. Nainstalujte prosím kompatibilní peněženku. Nelze otevřít platbu Nezačaté Kompresuje @@ -1381,7 +1412,30 @@ Komentář k webové stránce Komentář k externímu zdroji %1$d min čtení + + %1$d stopa + %1$d stopy + %1$d stopy + %1$d stop + + + +%1$d další stopa + +%1$d další stopy + +%1$d další stopy + +%1$d dalších stop + + Sdílený + Soukromý + Neznámá stopa + Načítání stopy… + Nahrát obrázek obalu + Vyberte čtvercový obrázek, který bude obalem této stopy. + Nahrát zvukový soubor + Vyberte MP3, WAV nebo FLAC. Při uložení bude nahrán na váš mediální server. + Zvukový soubor připraven k nahrání + Nahrávání… Nahrávání obalu a zvuku a publikování. Bude pokračovat, i když opustíte obrazovku. + Nahrávání selhalo: %1$s Načítání umístění Žádná lokace oprávnění Přidat varování o citlivém obsahu před zobrazením vašeho obsahu. Toto je ideální pro obsah NSFW (nebezpečné pro práci) nebo obsah, který někteří lidé mohou považovat za urážlivý nebo znepokojující @@ -1476,6 +1530,9 @@ Peněženka %1$s Chyba při otevírání aplikace pro podpis Signner aplikace nebyla nalezena. Zkontrolujte, zda aplikace nebyla odinstalována + Toto video nelze přehrát + Tento kodek nebo formát není na tomto zařízení podporován (%1$s). Zkuste jej otevřít v prohlížeči. + Otevřít v prohlížeči Požadavek na podpis byl zamítnut Žádost o podpis byla zamítnuta Ujistěte se, že podepisující aplikace autorizovala tuto transakci @@ -1624,6 +1681,7 @@ Přesto nahrát Nepodařilo se odstranit soukromá metadata z média. Nahrávání zrušeno. Nahrávání zrušeno + Nelze odstranit metadata z AVIF: %1$s Upravit koncept Přihlášení pomocí QR kódu Trasa @@ -1686,6 +1744,7 @@ Neplatné URI NWC připojení Posunout nahoru Posunout dolů + Změnit pořadí peněženek On-chain transakce Pro tento účet není k dispozici žádná on-chain adresa. Není nakonfigurován žádný on-chain backend. @@ -2284,6 +2343,8 @@ Uživatelská nastavení Audio hlavička Audio stopa + Hudební stopa + Hudební playlist Ocenění odznaků Definice odznaků Přijatá sada odznaků diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index bcda0b7079..22af06edea 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -569,6 +569,7 @@ anz der Bedingungen ist erforderlich Über Plattformen Themen + Verknüpfungen Neueste Version Kommentare Sei der Erste, der kommentiert. @@ -734,6 +735,34 @@ anz der Bedingungen ist erforderlich Noch keine Nest-Server. Füge unten einen hinzu oder wähle einen empfohlenen Server. Videos Artikel + Musik + Wiedergabelisten + Neuer Musiktitel + Neue Playlist + Titel + Titelname + Künstler + Name des Künstlers + Audio-URL + https://… .mp3 + URL des Coverbilds (optional) + Album (optional) + Dauer in Sekunden (optional) + Songtext oder Credits (optional) + Diesen Titel löschen + NIP-09-Löschung für diesen Musiktitel veröffentlichen? + Zur Playlist hinzufügen + Zur Playlist hinzufügen + Du hast noch keine eigenen Playlists. Verwende das Feld oben, um eine zu erstellen. + Alle Playlists verwalten + Neue Playlist + Playlist-Name + Erstellen + (Playlist ohne Titel) + + %1$d Titel + %1$d Titel + Private Lesezeichen Öffentliche Lesezeichen Zu den privaten Lesezeichen hinzufügen @@ -901,6 +930,7 @@ anz der Bedingungen ist erforderlich Adresse Zahlungsziel löschen Keine App gefunden, die diese Zahlung verarbeiten kann. Bitte installieren Sie eine kompatible Wallet. + Es ist keine App installiert, die Zahlungen vom Typ %1$s verarbeiten kann. Bitte installiere eine kompatible Wallet. Zahlung konnte nicht geöffnet werden Noch nicht angefangen Komprimieren @@ -1369,7 +1399,26 @@ anz der Bedingungen ist erforderlich Eine Webseite kommentieren Eine externe Ressource kommentieren %1$d Min. Lesezeit + + %1$d Titel + %1$d Titel + + + +%1$d weiterer Titel + +%1$d weitere Titel + + Kollaborativ + Privat + Unbekannter Titel + Titel wird geladen… + Coverbild hochladen + Wähle ein quadratisches Bild als Artwork für diesen Titel. + Audiodatei hochladen + Wähle eine MP3, WAV oder FLAC. Sie wird beim Speichern auf deinen Medienserver hochgeladen. + Audiodatei bereit zum Hochladen + Wird hochgeladen… Cover und Audio werden hochgeladen und veröffentlicht. Der Vorgang läuft weiter, auch wenn du den Bildschirm verlässt. + Hochladen fehlgeschlagen: %1$s Standort wird geladen Keine Standortberechtigungen Fügt eine Warnung für sensiblen Inhalt hinzu, bevor Ihr Inhalt angezeigt wird. Dies ist ideal für NSFW-Inhalte (nicht sicher für die Arbeit) oder Inhalte, die manche Menschen als anstößig oder verstörend empfinden könnten @@ -1464,6 +1513,9 @@ anz der Bedingungen ist erforderlich Brieftasche %1$s Fehler beim Öffnen der Signatur-App Die Unterzeichner-App konnte nicht gefunden werden. Prüfen Sie, ob die App nicht deinstalliert wurde + Dieses Video kann nicht abgespielt werden + Der Codec oder das Format wird auf diesem Gerät nicht unterstützt (%1$s). Versuche, es im Browser zu öffnen. + Im Browser öffnen Signaturanfrage abgelehnt Signaturanfrage abgelehnt Stellen Sie sicher, dass die Unterzeichner-Anwendung diese Transaktion autorisiert hat @@ -1612,6 +1664,7 @@ anz der Bedingungen ist erforderlich Trotzdem hochladen Private Metadaten konnten nicht aus der Mediendatei entfernt werden. Hochladen abgebrochen. Hochladen abgebrochen + Metadaten können nicht aus AVIF entfernt werden: %1$s Entwurf bearbeiten Einloggen mit QR-Code Route @@ -1674,6 +1727,7 @@ anz der Bedingungen ist erforderlich Ungültige NWC-Verbindungs-URI Nach oben Nach unten + Wallets neu anordnen On-Chain-Transaktionen Keine On-Chain-Adresse für dieses Konto verfügbar. Kein On-Chain-Backend konfiguriert. @@ -2264,6 +2318,8 @@ anz der Bedingungen ist erforderlich Benutzereinstellungen Audio-Header Audiospur + Musiktitel + Musik-Playlist Abzeichen-Auszeichnungen Abzeichen-Definitionen Akzeptiertes Abzeichen-Set diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index c82497ca09..946f0c27bc 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -131,6 +131,7 @@ Üzenet írása… Közzététel Mentés + Küldés Létrehozás Átnevezés Mégse @@ -561,6 +562,15 @@ Forrás: %1$s v%1$s Letöltés + Névjegy + Platformok + Témák + Hivatkozások + Legújabb kiadás + Megjegyzések + Legyen Ön az első hozzászóló. + Korábbi kiadások megjelenítése + Korábbi kiadások elrejtése Naptárak Rövidek Nyilvános csevegések @@ -721,6 +731,36 @@ Még nincsenek hangszoba-kiszolgálók. Adjon hozzá egyet alább, vagy válasszon egy ajánlott kiszolgálót. Videók Cikkek + Zene + Lejátszási listák + Részek + Hangműsor + Új zeneszám + Új lejátszási lista + Cím + Szám címe + Előadó + Előadó neve + Hang webcíme + https://… .mp3 + Borítókép webcíme (nem kötelező) + Album (nem kötelező) + Időtartam másodpercben (nem kötelező) + Dalszöveg vagy stáblista (nem kötelező) + Szám törlése + Közzétesz egy NIP-09 törlést ehhez a zeneszámhoz? + Hozzáadás a lejátszási listához + Hozzáadás a lejátszási listához + Önnek még nincs lejátszási listája. A fenti mező segítségével hozhat létre egyet. + Összes lejátszási lista kezelése + Új lejátszási lista + Lejátszási lista neve + Létrehozás + (cím nélküli lejátszási lista) + + %1$d szám + %1$d szám + Privát könyvjelzők Nyilvános könyvjelzők Hozzáadás a privát könyvjelzőkhöz @@ -1360,6 +1400,26 @@ Megjegyzés egy weboldalhoz Megjegyzés egy külső forráshoz Olvasási idő: %1$d perc + + %1$d szám + %1$d szám + + + +%1$d szám + +%1$d szám + + Közreműködési + Privát + Ismeretlen szám + Szám betöltése… + Egy borítókép feltöltése + Válasszon ki egy négyzet alakú képet, amely a szám borítójaként fog szolgálni. + Egy hangfájl feltöltése + Válasszon ki egy MP3-, WAV- vagy FLAC-fájlt. A „Mentés” gombra koppintva a fájl feltöltődik a médiakiszolgálóra. + A hangfájl készen áll a feltöltésre + Feltöltés… + Borító és hangfájl feltöltése, majd közzététel. Ez akkor is folytatódik, ha elhagyja a képernyőt. + Nem sikerült feltölteni: %1$s Helyszín betöltése… A helyszín-meghatározás nincs engedélyezve Hozzáadja az érzékeny tartalomra vonatkozó figyelmeztetést a tartalom megjelenítése előtt. Ez ideális bármilyen NSFW tartalom vagy olyan tartalom esetén, amelyet egyesek sértőnek vagy zavarónak találhatnak @@ -2260,6 +2320,12 @@ Felhasználói beállítások Audio-fejléc Hangsáv + Zeneszám + Zenei lejátszási lista + Hangműsor-rész + Hangműsor + Saját készítésű hangműsorok + Kedvenc hangműsorok Kitűződíjak Kitűzők meghatározása Elfogadott kitűzőlista diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index e773f40c5a..8f47054e2e 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -14,6 +14,12 @@ Ten post ma więcej niż %1$d hashtagów Ten post zawiera więcej niż %1$d hashtagów + + %1$d zestaw aktywów + %1$d zestawów aktywów + %1$d zestawów aktywów + %1$d zestawy aktywów + Wydarzenie jest wczytywane lub nie można go znaleźć na liście transmiterów 👀 Zdjęcie kanału @@ -129,6 +135,7 @@ Napisz wiadomość… Wyślij Zapisz + Wyślij Utwórz Zmień nazwę Anuluj @@ -567,6 +574,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Źródło: %1$s v%1$s Pobierz + O NIP-ie + Platformy + Tematy + Linki + Najnowsze wydanie + Komentarze + Bądź pierwszym, który skomentuje. + Pokaż starsze wersje + Ukryj starsze wersje Kalendarze Filmiki Czaty publiczne @@ -729,6 +745,30 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Brak serwerów Nest. Dodaj jeden poniżej lub wybierz polecany serwer. Filmy wideo Artykuły + Muzyka + Playlisty + Nowy utwór + Nowa playlista + Tytuł + Tytuł utworu + Artysta + Nazwa wykonawcy + Odnośnik audio + https://… .mp3 + Adres miniaturki (opcjonalnie) + Album (opcjonalnie) + Czas trwania w sekundach (opcjonalnie) + Teksty lub wkład twórczy (opcjonalne) + Usuń ten utwór + Opublikować NIP-09 informację o usunięciu tego utworu? + Dodaj do playlisty + Dodaj do playlisty + Nie masz jeszcze żadnych list odtwarzania. Skorzystaj z pola powyżej, aby utworzyć listę. + Zarządzaj wszystkimi playlistami + Nowa playlista + Nazwa playlisty + Utwórz + (niezatytułowana playlista) Prywatne Zakładki Publiczne zakładki Dodaj do prywatnych zakładek @@ -1368,6 +1408,18 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Skomentuj na stronie internetowej Skomentuj na zewnętrznym serwisie Czas czytania: %1$d min. + Współtworzona + Prywatna + Nieznany utwór + Ładowanie utworu… + Wgraj miniaturkę + Wybierz kwadratowe zdjęcie, które posłuży jako okładka tego utworu. + Wgraj plik audio + Wybierz plik w formacie MP3, WAV lub FLAC. Po kliknięciu „Zapisz” zostanie on przesłany na Twój serwer multimediów. + Plik audio gotowy do przesłania + Wgrywanie… + Wgraj miniaturę i plik audio, a następnie opublikuj. Proces ten będzie kontynuowany nawet po opuszczeniu ekranu. + Błąd wgrywania: %1$s Pobieranie lokalizacji Brak dostępu do lokalizacji Dodaje ostrzeżenie o wrażliwych treściach przed wyświetleniem treści. Jest to idealne dla dowolnych treści NSFW lub treści, które niektóre osoby mogą uznać za obraźliwe lub przeszkadzające @@ -2264,6 +2316,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Ustawienia użytkownika Nagłówek audio Ścieżka audio + Utwór muzyczny + Playlista muzyczna Odznaki Definicje odznak Zestaw odznak „Przyjęty” diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 07c1f69fdc..885d028787 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -729,6 +729,34 @@ Inga nest-servrar än. Lägg till en nedan eller välj en rekommenderad server. Videor Artiklar + Musik + Spellistor + Nytt musikspår + Ny spellista + Titel + Spårtitel + Artist + Artistens namn + Ljud-URL + https://… .mp3 + URL till omslagsbild (valfritt) + Album (valfritt) + Längd i sekunder (valfritt) + Sångtext eller medverkande (valfritt) + Ta bort det här spåret + Publicera en NIP-09-borttagning för det här musikspåret? + Lägg till i spellista + Lägg till i spellista + Du äger inga spellistor än. Använd fältet ovan för att skapa en. + Hantera alla spellistor + Ny spellista + Spellistans namn + Skapa + (namnlös spellista) + + %1$d spår + %1$d spår + Privata Bokmärken Publika Bokmärken Lägg till i Privata Bokmärken @@ -896,6 +924,7 @@ Adress Ta bort betalningsmottagare Ingen app hittades för att hantera denna betalning. Installera en kompatibel plånbok. + Ingen app installerad för att hantera %1$s-betalningar. Installera en kompatibel plånbok. Det gick inte att öppna betalningen Ej påbörjad Komprimerar @@ -1364,7 +1393,26 @@ Kommentera en webbplats Kommentera en extern resurs %1$d min läsning + + %1$d spår + %1$d spår + + + +%1$d spår till + +%1$d spår till + + Delad + Privat + Okänt spår + Läser in spår… + Ladda upp en omslagsbild + Välj en kvadratisk bild som omslag för det här spåret. + Ladda upp en ljudfil + Välj en MP3, WAV eller FLAC. Den laddas upp till din mediaserver när du sparar. + Ljudfil redo att laddas upp + Laddar upp… Laddar upp omslag och ljud och publicerar. Detta fortsätter även om du lämnar skärmen. + Uppladdning misslyckades: %1$s Laddar position Inga platsbehörigheter Lägger till en varning för känsligt innehåll innan ditt innehåll visas. Detta är idealiskt för NSFW-innehåll (inte säkert för arbete) eller innehåll som vissa personer kan uppleva som stötande eller störande @@ -1459,6 +1507,9 @@ Plånbok %1$s Fel vid öppning av signeringsapp Signeringsprogrammet kunde inte hittas. Kontrollera att appen inte har avinstallerats + Kan inte spela upp den här videon + Codec eller format stöds inte på den här enheten (%1$s). Försök öppna den i din webbläsare. + Öppna i webbläsare Signeringsförfrågan avvisad Underteckningsbegäran avvisad Kontrollera att signeringsprogrammet har godkänt denna transaktion @@ -1607,6 +1658,7 @@ Ladda upp ändå Kunde inte ta bort privat metadata från media. Uppladdning avbruten. Uppladdning avbruten + Kan inte ta bort metadata från AVIF: %1$s Redigera utkast Logga in med QR-kod Rutt @@ -1669,6 +1721,7 @@ Ogiltig URI för NWC-anslutning Flytta upp Flytta ner + Ändra ordning på plånböcker On-chain-transaktioner Ingen on-chain-adress tillgänglig för detta konto. Ingen on-chain-backend är konfigurerad. @@ -2259,6 +2312,8 @@ Användarinställningar Ljudhuvud Ljudspår + Musikspår + Musikspellista Utmärkelser Utmärkelsedefinitioner Accepterad emblemuppsättning diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 023af94949..184c82a7f8 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -725,6 +725,8 @@ 文章 音乐 播放列表 + 节目 + 播客 新音乐曲目 新建播放列表 标题 @@ -2305,6 +2307,10 @@ 音轨 音乐曲目 音乐播放列表 + 播客节目 + 播客秀 + 创作的播客 + 收藏的播客 徽章奖励 徽章定义 接受的徽章集 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a735fa370b..2df3e835a9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -764,6 +764,8 @@ Articles Music Playlists + Episodes + Podcasts New music track New playlist Title @@ -2687,6 +2689,10 @@ Audio Track Music Track Music Playlist + Podcast Episode + Podcast Show + Authored Podcasts + Favorite Podcasts Badge Awards Badge Definitions Accepted Badge Set diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 034b0d82b1..f8902f46e3 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 2fe66d88cc..cf17505499 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -121,6 +121,7 @@ object MaterialSymbols { val Group = MaterialSymbol("\uEA21") val GroupAdd = MaterialSymbol("\uE7F0") val Groups = MaterialSymbol("\uF233") + val Headphones = MaterialSymbol("\uF01F") val Hearing = MaterialSymbol("\uE023") val History = MaterialSymbol("\uE8B3") val Home = MaterialSymbol("\uE9B2") @@ -171,6 +172,7 @@ object MaterialSymbols { val PlayArrow = MaterialSymbol("\uE037") val PlayCircle = MaterialSymbol("\uE1C4") val PlayCircleOutline = MaterialSymbol("\uE1C4") + val Podcasts = MaterialSymbol("\uF048") val Poll = MaterialSymbol("\uF0CC") val PrivacyTip = MaterialSymbol("\uF0DC") val Public = MaterialSymbol("\uE80B") 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 index ff6d04c121..9179858e50 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -125,11 +125,17 @@ open class Note( fun inGatherers() = inGatherers ?: listOf().also { inGatherers = it } fun addGatherer(gatherer: NotesGatherer) { - inGatherers = inGatherers() + gatherer + val list = inGatherers() + if (gatherer !in list) { + inGatherers = inGatherers() + gatherer + } } fun removeGatherer(gatherer: NotesGatherer) { - inGatherers = inGatherers() - gatherer + val list = inGatherers() + if (gatherer in list) { + inGatherers = inGatherers() - gatherer + } } override fun removeNote(note: Note) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt index 6a5df9fa91..7b8f7334ea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt @@ -22,15 +22,17 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState -import kotlinx.coroutines.Job +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L @@ -47,6 +49,17 @@ private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L * rebuilding the relay REQ — which would otherwise lose EOSE state and * trigger a refetch on return. * + * The grace timer runs on a dedicated [Dispatchers.Default] scope driven by + * [Lifecycle.currentStateFlow] rather than on the composition's frame-clock + * coupled scope (`rememberCoroutineScope`). On a backgrounded app the UI + * frame clock stops ticking, so a timer scheduled there could be starved and + * the unsubscribe — and therefore the relay disconnect it triggers — might + * never run. This is most visible on the relay feed, whose dedicated one-off + * relay is kept connected by nothing else and would leak forever. Using a + * plain coroutine dispatcher keeps the timer firing while backgrounded; + * [collectLatest] cancels the pending delay automatically the moment the + * lifecycle returns to STARTED. + * * Use this for heavy feed subscriptions (home, video, discovery, chatroom list) * that should NOT run when the app is truly in the background. When an * always-on notification service keeps the relay client connected, these @@ -60,66 +73,23 @@ fun LifecycleAwareKeyDataSourceSubscription( state: T, dataSource: ComposeSubscriptionManager, ) { - val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() + LifecycleAwareSubscription( + key = state, + subscribe = { dataSource.subscribe(state) }, + unsubscribe = { dataSource.unsubscribe(state) }, + ) +} - DisposableEffect(state, lifecycleOwner) { - var isSubscribed = false - var pendingUnsubscribe: Job? = null - - fun subscribeNow() { - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (!isSubscribed) { - dataSource.subscribe(state) - isSubscribed = true - } - } - - fun scheduleUnsubscribe() { - if (!isSubscribed || pendingUnsubscribe != null) return - pendingUnsubscribe = - scope.launch { - delay(UNSUBSCRIBE_GRACE_MILLIS) - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } - pendingUnsubscribe = null - } - } - - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - subscribeNow() - } - - Lifecycle.Event.ON_STOP -> { - scheduleUnsubscribe() - } - - else -> {} - } - } - - lifecycleOwner.lifecycle.addObserver(observer) - - if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - subscribeNow() - } - - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } - } - } +@Composable +fun LifecycleAwareKeyDataSourceSubscription( + states: List, + dataSource: ComposeSubscriptionManager, +) { + LifecycleAwareSubscription( + key = states, + subscribe = { dataSource.subscribe(states) }, + unsubscribe = { dataSource.unsubscribe(states) }, + ) } @Composable @@ -127,64 +97,51 @@ fun LifecycleAwareKeyDataSourceSubscription( state: T, dataSource: MutableComposeSubscriptionManager, ) { - val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() + LifecycleAwareSubscription( + key = state, + subscribe = { dataSource.subscribe(state) }, + unsubscribe = { dataSource.unsubscribe(state) }, + ) +} - DisposableEffect(state, lifecycleOwner) { - var isSubscribed = false - var pendingUnsubscribe: Job? = null +@Composable +private fun LifecycleAwareSubscription( + key: Any?, + subscribe: () -> Unit, + unsubscribe: () -> Unit, +) { + val lifecycle = LocalLifecycleOwner.current.lifecycle - fun subscribeNow() { - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (!isSubscribed) { - dataSource.subscribe(state) - isSubscribed = true - } - } + DisposableEffect(key, lifecycle) { + // Background scope so the grace timer is not gated by the UI frame clock, + // which stops ticking while the app is backgrounded. + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - fun scheduleUnsubscribe() { - if (!isSubscribed || pendingUnsubscribe != null) return - pendingUnsubscribe = - scope.launch { + scope.launch { + // `subscribed` is confined to this single collector coroutine, so no + // cross-thread synchronization is needed for it. + var subscribed = false + lifecycle.currentStateFlow.collectLatest { current -> + if (current.isAtLeast(Lifecycle.State.STARTED)) { + if (!subscribed) { + subscribe() + subscribed = true + } + } else if (subscribed) { + // Stopped: keep the REQ alive for a short grace period. + // collectLatest cancels this delay if we return to STARTED first. delay(UNSUBSCRIBE_GRACE_MILLIS) - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } - pendingUnsubscribe = null - } - } - - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - subscribeNow() - } - - Lifecycle.Event.ON_STOP -> { - scheduleUnsubscribe() - } - - else -> {} + unsubscribe() + subscribed = false } } - - lifecycleOwner.lifecycle.addObserver(observer) - - if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - subscribeNow() } onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } + scope.cancel() + // Idempotent: removing an absent key is a cheap no-op. Guarantees the + // subscription is released even if the grace timer was still pending. + unsubscribe() } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt new file mode 100644 index 0000000000..0be7a93ab2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ShimmerPlaceholder.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp + +@Composable +fun ShimmerPlaceholder(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmerTranslate", + ) + + val shimmerColors = + listOf( + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + ) + + val brush = + Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim - 200f, translateAnim - 200f), + end = Offset(translateAnim, translateAnim), + ) + + Box(modifier.background(brush, MaterialTheme.shapes.small)) +} + +@Composable +fun NoteCardSkeleton(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ShimmerPlaceholder(Modifier.size(32.dp).clip(CircleShape)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + ShimmerPlaceholder(Modifier.width(120.dp).height(12.dp)) + ShimmerPlaceholder(Modifier.width(60.dp).height(10.dp)) + } + } + ShimmerPlaceholder(Modifier.fillMaxWidth().height(14.dp)) + ShimmerPlaceholder(Modifier.fillMaxWidth(0.7f).height(14.dp)) + ShimmerPlaceholder(Modifier.fillMaxWidth().height(180.dp)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt index 9f79b1d4f5..2d96b06cdc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt @@ -23,6 +23,10 @@ package com.vitorpamplona.amethyst.commons.ui.theme import androidx.compose.ui.graphics.Color // Primary brand colors +val AmethystBlue = Color(0xFF0096FF) +val AmethystBlueDark = Color(0xFF4DB8FF) +val AmethystPurple = Color(0xFF9A82DB) + val Primary50 = Color(red = 127, green = 103, blue = 190) val Primary60 = Color(red = 154, green = 130, blue = 219) val Primary70 = Color(red = 182, green = 157, blue = 248) @@ -71,6 +75,15 @@ val DarkWarningColorOnSecondSurface = Color(0xFFE1C419) val LightAllGoodColor = Color(0xFF339900) val DarkAllGoodColor = Color(0xFF99cc33) +// Semantic status colors for desktop +val StatusGreen = Color(0xFF4CAF50) +val StatusGreenDark = Color(0xFF81C784) +val StatusRed = Color(0xFFF44336) +val StatusRedDark = Color(0xFFEF9A9A) +val StatusAmber = Color(0xFFFFB300) +val StatusAmberDark = Color(0xFFFFD54F) +val StatusBlue = Color(0xFF2196F3) + // Fundraiser colors val LightFundraiserProgressColor = Color(0xFF3DB601) val DarkFundraiserProgressColor = Color(0xFF61A229) 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 3234edb759..43f6d5270c 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 @@ -27,23 +27,39 @@ import java.util.Calendar import java.util.Date import java.util.Locale -private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" -private const val MONTH_DATE_FORMAT = "MMM dd" +// Month + day without year — month name is textual so order is unambiguous across locales. +private const val MONTH_DATE_FORMAT = "MMM d" -private var locale = Locale.getDefault() -private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) -private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) -private var timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) +/** + * Per-thread cached [DateFormat] keyed off the current default [Locale]. + * + * `DateFormat`/`SimpleDateFormat` are mutable and not thread-safe. These + * formatters can be read from UI threads (composition) and from background + * coroutines. `ThreadLocal` gives each thread its own instance — no locks, + * no allocation per call, and we rebuild lazily on locale change. + */ +private class LocaleAwareFormatter( + private val build: (Locale) -> DateFormat, +) { + private val cache = ThreadLocal>() -private fun updateFormattersIfNeeded() { - if (locale != Locale.getDefault()) { - locale = Locale.getDefault() - yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) - monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) - timeOnlyFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, locale) + fun get(): DateFormat { + val current = Locale.getDefault() + val cached = cache.get() + if (cached != null && cached.first == current) return cached.second + val fresh = build(current) + cache.set(current to fresh) + return fresh } } +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private val yearFormatter = LocaleAwareFormatter { DateFormat.getDateInstance(DateFormat.MEDIUM, it) } +private val monthFormatter = LocaleAwareFormatter { SimpleDateFormat(MONTH_DATE_FORMAT, it) } + +// Locale-aware: en-US "2:32 PM" · en-GB "14:32" · de-DE "14:32" +private val timeOnlyFormatter = LocaleAwareFormatter { DateFormat.getTimeInstance(DateFormat.SHORT, it) } + /** * Formats a Unix timestamp (seconds) as a human-readable time ago string. * Returns strings like " • 5m", " • 2h", " • Dec 12" @@ -64,13 +80,11 @@ fun timeAgo( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - prefix + yearFormatter.format(time * 1000) + prefix + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_MONTH -> { - updateFormattersIfNeeded() - prefix + monthFormatter.format(time * 1000) + prefix + monthFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { @@ -131,13 +145,11 @@ fun dateFormatter( return when { timeDifference > TimeUtils.ONE_YEAR -> { - updateFormattersIfNeeded() - yearFormatter.format(time * 1000) + yearFormatter.get().format(time * 1000) } timeDifference > TimeUtils.ONE_DAY -> { - updateFormattersIfNeeded() - monthFormatter.format(time * 1000) + monthFormatter.get().format(time * 1000) } else -> { @@ -154,9 +166,9 @@ fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot) /** * Formats a Unix timestamp (seconds) as an absolute date/time string. Granularity * depends on how far in the past the timestamp is: - * - same day → time only (e.g. "14:32"), locale-aware - * - same year → "MMM dd, HH:mm" - * - older → "MMM dd, yyyy" + * - same day → locale-aware short time (e.g. "14:32" / "2:32 PM") + * - same year → month + day + short time (e.g. "May 28, 2:32 PM") + * - older → locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026") */ fun timeAbsolute( time: Long?, @@ -167,8 +179,6 @@ fun timeAbsolute( val prefix = if (withDot) " • " else "" if (time == 0L) return prefix + never - updateFormattersIfNeeded() - val timeMs = time * 1000 val now = Calendar.getInstance() val then = Calendar.getInstance().apply { timeInMillis = timeMs } @@ -176,12 +186,12 @@ fun timeAbsolute( val sameYear = now.get(Calendar.YEAR) == then.get(Calendar.YEAR) val sameDay = sameYear && now.get(Calendar.DAY_OF_YEAR) == then.get(Calendar.DAY_OF_YEAR) - val timeOfDay = timeOnlyFormatter.format(Date(timeMs)) + val timeOfDay = timeOnlyFormatter.get().format(Date(timeMs)) return when { sameDay -> prefix + timeOfDay - sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay - else -> prefix + yearFormatter.format(timeMs) + sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay + else -> prefix + yearFormatter.get().format(timeMs) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 00d8e26fab..92d5b123be 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -105,4 +105,12 @@ object DesktopPreferences { val preferredBlossomServer: String get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER + + private const val KEY_SIDEBAR_COLLAPSED = "sidebar_collapsed" + + var sidebarCollapsed: Boolean + get() = prefs.getBoolean(KEY_SIDEBAR_COLLAPSED, false) + set(value) { + prefs.putBoolean(KEY_SIDEBAR_COLLAPSED, value) + } } 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 fef81a722b..d1548c194e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -104,8 +105,8 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout -import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState +import com.vitorpamplona.amethyst.desktop.ui.deck.MainSidebar import com.vitorpamplona.amethyst.desktop.ui.deck.PinnedNavBarState import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState @@ -263,6 +264,7 @@ fun main() { var showAppDrawer by remember { mutableStateOf(false) } var showAddColumnDialog by remember { mutableStateOf(false) } var showImportFollowListDialog by remember { mutableStateOf(false) } + val feedSearchActiveState = remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild var torSettings by remember { @@ -443,6 +445,18 @@ fun main() { ) } Menu("View") { + Item( + "Search", + shortcut = + if (isMacOS) { + KeyShortcut(Key.F, meta = true) + } else { + KeyShortcut(Key.F, ctrl = true) + }, + onClick = { + feedSearchActiveState.value = !feedSearchActiveState.value + }, + ) Item( "App Drawer", shortcut = @@ -607,39 +621,46 @@ fun main() { LocalIsImmersiveFullscreen provides immersiveFullscreenState, ) { key(appRestartKey) { - App( - layoutMode = layoutMode, - onLayoutModeChange = { newMode -> - layoutMode = newMode - DesktopPreferences.layoutMode = newMode.name + CompositionLocalProvider( + com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive provides feedSearchActiveState, + com.vitorpamplona.amethyst.desktop.ui.theme.LocalOpenFullSearch provides { + navigateToScreen?.invoke(DeckColumnType.Search) }, - deckState = deckState, - workspaceManager = workspaceManager, - accountManager = accountManager, - showComposeDialog = showComposeDialog, - showAppDrawer = showAppDrawer, - onShowComposeDialog = { showComposeDialog = true }, - onShowReplyDialog = { event -> - replyToNote = event - showComposeDialog = true - }, - onDismissComposeDialog = { - showComposeDialog = false - replyToNote = null - }, - onDismissAppDrawer = { showAppDrawer = false }, - onShowAppDrawer = { showAppDrawer = true }, - replyToNote = replyToNote, - showImportFollowListDialog = showImportFollowListDialog, - onShowImportFollowListDialog = { showImportFollowListDialog = true }, - onDismissImportFollowListDialog = { showImportFollowListDialog = false }, - onRestartApp = { appRestartKey++ }, - torManager = torManager, - torTypeFlow = torTypeFlow, - externalPortFlow = externalPortFlow, - initialTorSettings = torSettings, - onNavigateToScreen = { navigateToScreen = it }, - ) + ) { + App( + layoutMode = layoutMode, + onLayoutModeChange = { newMode -> + layoutMode = newMode + DesktopPreferences.layoutMode = newMode.name + }, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = showComposeDialog, + showAppDrawer = showAppDrawer, + onShowComposeDialog = { showComposeDialog = true }, + onShowReplyDialog = { event -> + replyToNote = event + showComposeDialog = true + }, + onDismissComposeDialog = { + showComposeDialog = false + replyToNote = null + }, + onDismissAppDrawer = { showAppDrawer = false }, + onShowAppDrawer = { showAppDrawer = true }, + replyToNote = replyToNote, + showImportFollowListDialog = showImportFollowListDialog, + onShowImportFollowListDialog = { showImportFollowListDialog = true }, + onDismissImportFollowListDialog = { showImportFollowListDialog = false }, + onRestartApp = { appRestartKey++ }, + torManager = torManager, + torTypeFlow = torTypeFlow, + externalPortFlow = externalPortFlow, + initialTorSettings = torSettings, + onNavigateToScreen = { navigateToScreen = it }, + ) + } } } } @@ -844,6 +865,7 @@ fun App( } is AccountState.ConnectingRelays -> {} + is AccountState.Loading -> {} } } @@ -904,8 +926,10 @@ fun App( if (current != null) { accountManager.loadNwcConnection(current.npub) } + } else { + // No saved account found → show login screen + accountManager.setLoggedOut() } - // If failure: state remains LoggedOut → login screen shows automatically } onDispose { @@ -936,6 +960,28 @@ fun App( com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore, ) { when (accountState) { + is AccountState.Loading -> { + // Branded loading screen while accounts load from storage + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp, + ) + Spacer(Modifier.height(16.dp)) + Text( + "Amethyst", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + } + is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, @@ -1346,6 +1392,106 @@ fun MainContent( Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize().weight(1f)) { + // Shared sidebar for both layout modes + if (!isImmersive) { + val allAccountsState by accountManager.allAccounts.collectAsState() + val searchActiveState = com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current + var showAddAccountDialog by remember { mutableStateOf(false) } + + // Derive active column type based on layout mode + val activeColumnType = + when (layoutMode) { + LayoutMode.DECK -> { + val deckColumns by deckState.columns.collectAsState() + val focusedIdx by deckState.focusedColumnIndex.collectAsState() + deckColumns.getOrNull(focusedIdx)?.type + } + LayoutMode.SINGLE_PANE -> { + val currentScreen by singlePaneState.currentScreen.collectAsState() + currentScreen + } + } + + Box { + MainSidebar( + activeNpub = accountManager.currentAccount()?.npub, + allAccounts = allAccountsState, + localCache = localCache, + onSwitchAccount = { npub -> + scope.launch(Dispatchers.IO) { + accountManager.switchAccount(npub) + } + }, + onAddAccount = { showAddAccountDialog = true }, + onRemoveAccount = { npub -> + scope.launch(Dispatchers.IO) { + accountManager.removeAccountFromStorage(npub) + } + }, + onAddColumn = onShowAppDrawer, + onOpenSettings = { + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(DeckColumnType.Settings) + } + } + }, + onNavigate = { type -> + searchActiveState.value = false + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + } + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(type) + } + } + }, + activeColumnType = activeColumnType, + onShowImportFollowListDialog = onShowImportFollowListDialog, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + torStatus = torStatus, + ) + + // Dim sidebar when feed search is active + if (com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current.value) { + Box( + modifier = + Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = 0.3f)), + ) + } + } + + VerticalDivider() + + if (showAddAccountDialog) { + com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog( + accountManager = accountManager, + onDismiss = { showAddAccountDialog = false }, + onAccountAdded = { + showAddAccountDialog = false + scope.launch(Dispatchers.IO) { + accountManager.refreshAccountList() + } + }, + ) + } + } + when (layoutMode) { LayoutMode.SINGLE_PANE -> { val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState() @@ -1377,55 +1523,6 @@ fun MainContent( } LayoutMode.DECK -> { - if (!isImmersive) { - val allAccountsState by accountManager.allAccounts.collectAsState() - var showAddAccountDialog by remember { mutableStateOf(false) } - - DeckSidebar( - activeNpub = accountManager.currentAccount()?.npub, - allAccounts = allAccountsState, - localCache = localCache, - onSwitchAccount = { npub -> - scope.launch(Dispatchers.IO) { - accountManager.switchAccount(npub) - } - }, - onAddAccount = { showAddAccountDialog = true }, - onRemoveAccount = { npub -> - scope.launch(Dispatchers.IO) { - accountManager.removeAccountFromStorage(npub) - } - }, - onAddColumn = onShowAppDrawer, - onOpenSettings = { - if (deckState.hasColumnOfType(DeckColumnType.Settings)) { - deckState.focusExistingColumn(DeckColumnType.Settings) - } else { - deckState.addColumn(DeckColumnType.Settings) - } - }, - onShowImportFollowListDialog = onShowImportFollowListDialog, - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - torStatus = torStatus, - ) - - VerticalDivider() - - if (showAddAccountDialog) { - com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog( - accountManager = accountManager, - onDismiss = { showAddAccountDialog = false }, - onAccountAdded = { - showAddAccountDialog = false - scope.launch(Dispatchers.IO) { - accountManager.refreshAccountList() - } - }, - ) - } - } - DeckLayout( deckState = deckState, relayManager = relayManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 9b580b467a..4c4ef4ebc8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -69,6 +69,8 @@ import kotlinx.coroutines.withTimeout import java.io.File sealed class AccountState { + data object Loading : AccountState() + data object LoggedOut : AccountState() data object ConnectingRelays : AccountState() @@ -123,7 +125,7 @@ class AccountManager internal constructor( private val _allAccounts = MutableStateFlow>(persistentListOf()) val allAccounts: StateFlow> = _allAccounts.asStateFlow() - private val _accountState = MutableStateFlow(AccountState.LoggedOut) + private val _accountState = MutableStateFlow(AccountState.Loading) val accountState: StateFlow = _accountState.asStateFlow() private val _nwcConnection = MutableStateFlow(null) @@ -463,6 +465,10 @@ class AccountManager internal constructor( accountStorage.setCurrentAccount(npub) } + fun setLoggedOut() { + _accountState.value = AccountState.LoggedOut + } + fun setConnectingRelays() { _accountState.value = AccountState.ConnectingRelays } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt index 6a8c3557bf..bf77e481fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt @@ -24,272 +24,92 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlue +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlueDark +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPurple /** - * Per-OS Material3 [ColorScheme]s tuned to match each platform's native surface - * tones. The OS accent (resolved via [PlatformAccent]) is plumbed in as `primary` - * so links, focus rings, and selection highlights match the rest of the user's - * system. - * - * Surface tones come from each OS's reference palettes: - * - macOS dark: NSWindowBackgroundColor / NSAlternateSelectedControlColor. - * - macOS light: NSColor systemBackgroundColor (#FFFFFF) / window background (#ECECEC). - * - GNOME dark: libadwaita `@window_bg_color` (#242424) / `@view_bg_color` (#1E1E1E). - * - GNOME light: libadwaita `@window_bg_color` (#FAFAFA) / `@view_bg_color` (#FFFFFF). - * - KDE Breeze dark/light defaults. - * - Windows 11 dark/light: WinUI mica reference values. + * Unified Amethyst brand [ColorScheme] — consistent visual identity across all + * platforms. Uses cyan/blue (#0096FF) as primary accent, with Amethyst purple + * as tertiary heritage color. */ object PlatformColorScheme { - fun resolve( - dark: Boolean, - accent: Color, - ): ColorScheme = - when (PlatformInfo.current) { - Platform.MACOS -> if (dark) macOSDark(accent) else macOSLight(accent) - Platform.GNOME -> if (dark) gnomeDark(accent) else gnomeLight(accent) - Platform.KDE -> if (dark) kdeDark(accent) else kdeLight(accent) - Platform.WINDOWS -> if (dark) windowsDark(accent) else windowsLight(accent) - else -> if (dark) genericDark(accent) else genericLight(accent) - } + fun resolve(dark: Boolean): ColorScheme = if (dark) amethystDark() else amethystLight() - // ── macOS ───────────────────────────────────────────────────────────────── + private fun amethystLight() = + lightColorScheme( + primary = AmethystBlue, + onPrimary = Color.White, + primaryContainer = AmethystBlue.copy(alpha = 0.12f).compositeOver(Color.White), + onPrimaryContainer = AmethystBlue, + secondary = Color(0xFF5E8FAD), + onSecondary = Color.White, + secondaryContainer = Color(0xFF5E8FAD).copy(alpha = 0.12f).compositeOver(Color.White), + onSecondaryContainer = Color(0xFF5E8FAD), + tertiary = AmethystPurple, + onTertiary = Color.White, + tertiaryContainer = AmethystPurple.copy(alpha = 0.12f).compositeOver(Color.White), + onTertiaryContainer = AmethystPurple, + background = Color(0xFFF2F2F7), + onBackground = Color(0xFF1C1C1E), + surface = Color.White, + onSurface = Color(0xFF1C1C1E), + surfaceVariant = Color(0xFFF0F0F5), + onSurfaceVariant = Color(0xFF6E6E73), + surfaceContainer = Color(0xFFF7F7FA), + surfaceContainerHigh = Color(0xFFEEEEF2), + surfaceContainerHighest = Color(0xFFE5E5EA), + surfaceContainerLow = Color(0xFFFAFAFC), + surfaceContainerLowest = Color.White, + surfaceDim = Color(0xFFE8E8ED), + surfaceBright = Color.White, + outline = Color(0xFFE0E0E0), + outlineVariant = Color(0xFFEBEBEB), + inverseSurface = Color(0xFF2C2C2E), + inverseOnSurface = Color(0xFFF2F2F7), + inversePrimary = AmethystBlueDark, + error = Color(0xFFBA1A1A), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + ) - private fun macOSDark(accent: Color) = + private fun amethystDark() = darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF1E1E1E), - onBackground = Color(0xFFE5E5E5), + primary = AmethystBlueDark, + onPrimary = Color.White, + primaryContainer = AmethystBlueDark.copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onPrimaryContainer = AmethystBlueDark, + secondary = Color(0xFF7EAEC8), + onSecondary = Color.White, + secondaryContainer = Color(0xFF7EAEC8).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onSecondaryContainer = Color(0xFF7EAEC8), + tertiary = Color(0xFFB6A0E0), + onTertiary = Color.White, + tertiaryContainer = Color(0xFFB6A0E0).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onTertiaryContainer = Color(0xFFB6A0E0), + background = Color(0xFF121212), + onBackground = Color(0xFFE5E5EA), surface = Color(0xFF1E1E1E), - onSurface = Color(0xFFE5E5E5), + onSurface = Color(0xFFE5E5EA), surfaceVariant = Color(0xFF2A2A2A), - onSurfaceVariant = Color(0xFFB8B8B8), + onSurfaceVariant = Color(0xFF9E9EA3), surfaceContainer = Color(0xFF252525), - surfaceContainerHigh = Color(0xFF2D2D2D), - surfaceContainerHighest = Color(0xFF353535), + surfaceContainerHigh = Color(0xFF2E2E2E), + surfaceContainerHighest = Color(0xFF383838), surfaceContainerLow = Color(0xFF1A1A1A), surfaceContainerLowest = Color(0xFF141414), surfaceDim = Color(0xFF1A1A1A), - surfaceBright = Color(0xFF353535), - outline = Color(0xFF6A6A6A), - outlineVariant = Color(0xFF3A3A3A), + surfaceBright = Color(0xFF383838), + outline = Color(0xFF3A3A3A), + outlineVariant = Color(0xFF2E2E2E), + inverseSurface = Color(0xFFE5E5EA), + inverseOnSurface = Color(0xFF1C1C1E), + inversePrimary = AmethystBlue, + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), ) - - private fun macOSLight(accent: Color): ColorScheme { - // Apple's accent palette includes Yellow (#F8BA00) and Graphite (#8C8C8C) - // which have terrible contrast on any light surface. Darken high-luminance - // accents so the primary color stays readable without disabling the user's - // preference — the hue is preserved, just pulled toward a usable lightness. - val readable = darkenForLight(accent) - return lightColorScheme( - primary = readable, - onPrimary = onAccent(readable), - secondary = readable, - tertiary = readable, - // Apple's light-mode main content background is near-white (#F5F5F7 / - // Apple's secondarySystemBackgroundColor); the #ECECEC gray is only - // used behind the title bar chrome — which we map to surfaceContainer. - background = Color(0xFFF5F5F7), - onBackground = Color(0xFF1A1A1A), - surface = Color(0xFFFFFFFF), - onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFECECEC), - onSurfaceVariant = Color(0xFF555555), - surfaceContainer = Color(0xFFECECEC), - surfaceContainerHigh = Color(0xFFE5E5E5), - surfaceContainerHighest = Color(0xFFDDDDDD), - surfaceContainerLow = Color(0xFFF0F0F0), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFB0B0B0), - outlineVariant = Color(0xFFD8D8D8), - ) - } - - /** - * Scales an accent color toward black until its relative luminance is at - * most [maxLuminance]. Preserves hue (all RGB channels scale by the same - * factor). Only applied on light surfaces — on dark surfaces the raw accent - * already has good contrast. - * - * 0.38 was picked empirically: it keeps a saturated blue visible without - * darkening it, while pulling yellow/graphite down to a readable amber/gray. - */ - private fun darkenForLight( - c: Color, - maxLuminance: Float = 0.38f, - ): Color { - val lum = 0.2126f * c.red + 0.7152f * c.green + 0.0722f * c.blue - if (lum <= maxLuminance) return c - val scale = maxLuminance / lum - return Color( - red = (c.red * scale).coerceIn(0f, 1f), - green = (c.green * scale).coerceIn(0f, 1f), - blue = (c.blue * scale).coerceIn(0f, 1f), - alpha = c.alpha, - ) - } - - // ── GNOME (libadwaita) ──────────────────────────────────────────────────── - - private fun gnomeDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF242424), - onBackground = Color(0xFFFFFFFF), - surface = Color(0xFF1E1E1E), - onSurface = Color(0xFFFFFFFF), - surfaceVariant = Color(0xFF323232), - onSurfaceVariant = Color(0xFFCCCCCC), - surfaceContainer = Color(0xFF2C2C2C), - surfaceContainerHigh = Color(0xFF383838), - surfaceContainerHighest = Color(0xFF424242), - surfaceContainerLow = Color(0xFF222222), - surfaceContainerLowest = Color(0xFF1A1A1A), - outline = Color(0xFF5E5E5E), - outlineVariant = Color(0xFF3A3A3A), - ) - - private fun gnomeLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFFAFAFA), - onBackground = Color(0xFF1A1A1A), - surface = Color(0xFFFFFFFF), - onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFF0F0F0), - onSurfaceVariant = Color(0xFF5E5E5E), - surfaceContainer = Color(0xFFF4F4F4), - surfaceContainerHigh = Color(0xFFEDEDED), - surfaceContainerHighest = Color(0xFFE5E5E5), - surfaceContainerLow = Color(0xFFF9F9F9), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFB0B0B0), - outlineVariant = Color(0xFFD4D4D4), - ) - - // ── KDE Plasma (Breeze) ─────────────────────────────────────────────────── - - private fun kdeDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF1B1E20), - onBackground = Color(0xFFFCFCFC), - surface = Color(0xFF232629), - onSurface = Color(0xFFFCFCFC), - surfaceVariant = Color(0xFF2A2E32), - onSurfaceVariant = Color(0xFFBDC3C7), - surfaceContainer = Color(0xFF272A2E), - surfaceContainerHigh = Color(0xFF31353A), - surfaceContainerHighest = Color(0xFF3B4045), - surfaceContainerLow = Color(0xFF1F2225), - surfaceContainerLowest = Color(0xFF18191B), - outline = Color(0xFF4D5258), - outlineVariant = Color(0xFF34383C), - ) - - private fun kdeLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFEFF0F1), - onBackground = Color(0xFF232629), - surface = Color(0xFFFCFCFC), - onSurface = Color(0xFF232629), - surfaceVariant = Color(0xFFE5E9EC), - onSurfaceVariant = Color(0xFF4D4D4D), - surfaceContainer = Color(0xFFF2F3F4), - surfaceContainerHigh = Color(0xFFEAECEE), - surfaceContainerHighest = Color(0xFFE0E3E5), - surfaceContainerLow = Color(0xFFF7F8F9), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFBABEC2), - outlineVariant = Color(0xFFD9DCDF), - ) - - // ── Windows 11 (WinUI 3 / Mica) ─────────────────────────────────────────── - - private fun windowsDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF202020), - onBackground = Color(0xFFFFFFFF), - surface = Color(0xFF2B2B2B), - onSurface = Color(0xFFFFFFFF), - surfaceVariant = Color(0xFF323232), - onSurfaceVariant = Color(0xFFCCCCCC), - surfaceContainer = Color(0xFF272727), - surfaceContainerHigh = Color(0xFF323232), - surfaceContainerHighest = Color(0xFF3D3D3D), - surfaceContainerLow = Color(0xFF1F1F1F), - surfaceContainerLowest = Color(0xFF1A1A1A), - outline = Color(0xFF5E5E5E), - outlineVariant = Color(0xFF3A3A3A), - ) - - private fun windowsLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFF3F3F3), - onBackground = Color(0xFF1A1A1A), - surface = Color(0xFFFBFBFB), - onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFEDEDED), - onSurfaceVariant = Color(0xFF555555), - surfaceContainer = Color(0xFFF6F6F6), - surfaceContainerHigh = Color(0xFFEDEDED), - surfaceContainerHighest = Color(0xFFE5E5E5), - surfaceContainerLow = Color(0xFFFAFAFA), - surfaceContainerLowest = Color(0xFFFFFFFF), - outline = Color(0xFFB0B0B0), - outlineVariant = Color(0xFFD8D8D8), - ) - - // ── Generic (other Linux DEs / Unknown) ────────────────────────────────── - - private fun genericDark(accent: Color) = - darkColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFF1E1E1E), - surface = Color(0xFF1E1E1E), - surfaceVariant = Color(0xFF2A2A2A), - ) - - private fun genericLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - ) - - /** - * Picks readable text color (white or black) on top of the given accent based on - * its perceived luminance (Rec. 709 weights). - */ - private fun onAccent(accent: Color): Color { - val luminance = 0.2126f * accent.red + 0.7152f * accent.green + 0.0722f * accent.blue - return if (luminance > 0.55f) Color.Black else Color.White - } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt index fbd1831404..e2935bec2d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt @@ -25,67 +25,15 @@ import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp /** - * Per-OS Material3 [Shapes] tuned to match each platform's native rounding language. - * Material's defaults (4 / 4 / 0 dp) read as Android — these values match what users - * see in their OS's first-party apps. - * - * - macOS (Sonoma+): ~10 / 12 / 16 dp continuous-style corners. - * - GNOME (libadwaita): 9 / 12 / 16 dp — adw_dialog / adw_card baseline. - * - KDE (Breeze): 6 / 8 / 12 dp — Breeze prefers tighter rounding than libadwaita. - * - Windows (WinUI 3): 4 / 8 / 8 dp — WinUI's mica surfaces use modest rounding. + * Unified Amethyst brand [Shapes] — consistent rounding across all platforms. */ object PlatformShapes { - val current: Shapes by lazy { - when (PlatformInfo.current) { - Platform.MACOS -> { - Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(10.dp), - large = RoundedCornerShape(14.dp), - extraLarge = RoundedCornerShape(20.dp), - ) - } - - Platform.GNOME -> { - Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(9.dp), - medium = RoundedCornerShape(12.dp), - large = RoundedCornerShape(16.dp), - extraLarge = RoundedCornerShape(24.dp), - ) - } - - Platform.KDE -> { - Shapes( - extraSmall = RoundedCornerShape(4.dp), - small = RoundedCornerShape(6.dp), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(12.dp), - extraLarge = RoundedCornerShape(16.dp), - ) - } - - Platform.WINDOWS -> { - Shapes( - extraSmall = RoundedCornerShape(4.dp), - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(8.dp), - extraLarge = RoundedCornerShape(12.dp), - ) - } - - Platform.LINUX_OTHER, Platform.UNKNOWN -> { - Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(10.dp), - large = RoundedCornerShape(14.dp), - extraLarge = RoundedCornerShape(20.dp), - ) - } - } - } + val current: Shapes = + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), + ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt index 739d0ee0a6..3b508d089c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt @@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.desktop.platform import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope +import com.vitorpamplona.amethyst.desktop.ui.theme.AmethystSpacing +import com.vitorpamplona.amethyst.desktop.ui.theme.LocalIsDarkTheme +import com.vitorpamplona.amethyst.desktop.ui.theme.LocalSpacing /** * Wraps content in a [MaterialTheme] tuned for the host OS: native fonts, accent @@ -41,14 +45,18 @@ fun PlatformMaterialTheme( isDark: Boolean, content: @Composable () -> Unit, ) { - val accent = remember { PlatformAccent.systemAccent() } - val colorScheme = remember(isDark, accent) { PlatformColorScheme.resolve(isDark, accent) } - MaterialTheme( - colorScheme = colorScheme, - typography = PlatformTypography.current, - shapes = PlatformShapes.current, - content = content, - ) + val colorScheme = remember(isDark) { PlatformColorScheme.resolve(isDark) } + CompositionLocalProvider( + LocalSpacing provides AmethystSpacing(), + LocalIsDarkTheme provides isDark, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = PlatformTypography.current, + shapes = PlatformShapes.current, + content = content, + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt index f11e028393..97e6195241 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt @@ -40,19 +40,7 @@ object PlatformTypography { val current: Typography by lazy { build(PlatformFonts.ui) } private fun build(family: FontFamily): Typography { - // Per-OS letter spacing offset applied to display/headline styles. - val tightening: TextUnit = - when (PlatformInfo.current) { - Platform.MACOS -> (-0.4).sp - - // SF Pro is set tight at large sizes - Platform.GNOME -> (-0.2).sp - - // Adwaita Sans is mildly tight - Platform.KDE, Platform.WINDOWS -> 0.sp - - else -> 0.sp - } + val tightening: TextUnit = (-0.3).sp fun ts( size: Int, @@ -68,9 +56,9 @@ object PlatformTypography { ) return Typography( - displayLarge = ts(57, 64, FontWeight.Normal, tightening), - displayMedium = ts(45, 52, FontWeight.Normal, tightening), - displaySmall = ts(36, 44, FontWeight.Normal, tightening), + displayLarge = ts(57, 64, FontWeight.Light, tightening), + displayMedium = ts(45, 52, FontWeight.Light, tightening), + displaySmall = ts(36, 44, FontWeight.Light, tightening), headlineLarge = ts(32, 40, FontWeight.SemiBold, tightening), headlineMedium = ts(28, 36, FontWeight.SemiBold, tightening), headlineSmall = ts(24, 32, FontWeight.SemiBold, tightening), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index 2b2249b92d..32e0479e63 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -97,12 +97,12 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.launch -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.text.DateFormat +import java.util.Date import java.util.Locale -private val articleDateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private val articleDateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) /** * Parses a NIP-23 address tag in the format "30023:pubkey:d-tag". @@ -352,11 +352,7 @@ fun ArticleReaderScreen( val publishedAt = article?.let { art -> val ts = art.publishedAt() ?: art.createdAt - Instant - .ofEpochSecond(ts) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .format(articleDateFormat) + articleDateFormat.format(Date(ts * 1000L)) } // Author info from local cache 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 index 9d8f5d3b65..135ad52fbe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -36,7 +36,6 @@ 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.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -220,7 +219,7 @@ fun ComposeNoteDialog( .dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget) .then( if (isDragOver) { - Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium) } else { Modifier }, 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 index 4ab3216861..caa205a864 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt @@ -43,12 +43,14 @@ 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.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen +import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed import com.vitorpamplona.amethyst.desktop.account.AccountState import java.awt.Toolkit import java.awt.datatransfer.StringSelection @@ -70,11 +72,11 @@ fun DevSettingsSection( .fillMaxWidth() .border( width = 2.dp, - color = Color(0xFFFF9800), // Orange warning color - shape = RoundedCornerShape(8.dp), + color = StatusAmber, + shape = MaterialTheme.shapes.small, ).background( - color = Color(0xFF332200), // Dark orange tint - shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, ).padding(16.dp), ) { // Warning header @@ -85,13 +87,13 @@ fun DevSettingsSection( Icon( MaterialSymbols.Warning, contentDescription = "Warning", - tint = Color(0xFFFF9800), + tint = StatusAmber, ) Text( "Developer Settings", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = Color(0xFFFF9800), + color = StatusAmber, ) } @@ -105,7 +107,7 @@ fun DevSettingsSection( Spacer(Modifier.height(16.dp)) - HorizontalDivider(color = Color(0xFFFF9800).copy(alpha = 0.3f)) + HorizontalDivider(color = StatusAmber.copy(alpha = 0.3f)) Spacer(Modifier.height(16.dp)) @@ -114,7 +116,7 @@ fun DevSettingsSection( onClick = { showKeys = !showKeys }, colors = ButtonDefaults.outlinedButtonColors( - contentColor = Color(0xFFFF9800), + contentColor = StatusAmber, ), ) { Text(if (showKeys) "Hide Keys" else "Show Keys") @@ -180,7 +182,7 @@ private fun KeyRow( style = MaterialTheme.typography.labelMedium, color = if (isSensitive) { - Color(0xFFFF5252) + StatusRed } else { MaterialTheme.colorScheme.onSurface }, @@ -196,7 +198,7 @@ private fun KeyRow( ButtonDefaults.buttonColors( containerColor = if (copiedRecently) { - Color(0xFF4CAF50) + StatusGreen } else { MaterialTheme.colorScheme.primaryContainer }, @@ -222,7 +224,7 @@ private fun KeyRow( ), color = if (isSensitive) { - Color(0xFFFF5252) + StatusRed } else { MaterialTheme.colorScheme.onSurfaceVariant }, @@ -230,7 +232,7 @@ private fun KeyRow( Modifier .fillMaxWidth() .background( - color = Color(0xFF1A1A1A), + color = MaterialTheme.colorScheme.onBackground, shape = RoundedCornerShape(4.dp), ).padding(8.dp), ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 5ca2760bf1..28a0a761a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -33,10 +34,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -148,14 +149,15 @@ private fun DraftCard( onClick: () -> Unit, onDelete: () -> Unit, ) { - Card( + OutlinedCard( onClick = onClick, modifier = Modifier.fillMaxWidth(), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Row( modifier = Modifier.padding(16.dp).fillMaxWidth(), 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 4bc55e7cee..9d361404b7 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 @@ -20,8 +20,19 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,13 +42,18 @@ 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.heightIn 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.text.BasicTextField import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -49,44 +65,66 @@ 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.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus import com.vitorpamplona.amethyst.commons.compose.elements.BoostedMark import com.vitorpamplona.amethyst.commons.compose.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.richtext.UrlParser +import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState +import com.vitorpamplona.amethyst.commons.search.QuerySerializer +import com.vitorpamplona.amethyst.commons.search.SearchResultFilter import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import com.vitorpamplona.amethyst.desktop.SearchHistoryStore import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.feeds.DesktopCustomFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createCustomFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor +import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -192,6 +230,7 @@ fun FeedNoteCard( val displayData = remember(originalEvent, metadataState) { originalEvent.toNoteDisplayData(localCache) } NoteCard( note = displayData, + modifier = Modifier.fillMaxWidth(), localCache = localCache, onClick = { onNavigateToThread(originalEvent.id) }, onAuthorClick = onNavigateToProfile, @@ -245,6 +284,7 @@ fun FeedNoteCard( val displayData = remember(event, metadataState) { event.toNoteDisplayData(localCache) } NoteCard( note = displayData, + modifier = Modifier.fillMaxWidth(), localCache = localCache, onClick = { onNavigateToThread(event.id) }, onAuthorClick = onNavigateToProfile, @@ -297,7 +337,12 @@ fun FeedScreen( onNavigateToThread: (String) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {}, onNavigateToRelays: () -> Unit = {}, + onSearchClick: () -> Unit = {}, ) { + val feedSearchActiveState = com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current + var searchActive by feedSearchActiveState + val onSearchActiveChange: (Boolean) -> Unit = { searchActive = it } + val openFullSearch = com.vitorpamplona.amethyst.desktop.ui.theme.LocalOpenFullSearch.current val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() val followedUsers by localCache.followedUsers.collectAsState() @@ -557,32 +602,16 @@ fun FeedScreen( } Box(modifier = Modifier.fillMaxSize()) { + // Layer 1: Feed content (scrollable, behind scrim) ReadingColumn { - // Header: pinned feed tabs + "Show More +" - FeedTabsHeader( - feedMode = feedMode, - activeFeedId = activeFeedId, - onFeedModeChange = { mode -> - feedMode = mode - activeFeedId = null - activeFeedSource = null - if (mode != FeedMode.CUSTOM) { - DesktopPreferences.feedMode = mode - } - }, - onNavigateToFeed = { feed -> - val source = feed.source - if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) { - activeFeedId = feed.id - activeFeedSource = source - feedMode = FeedMode.CUSTOM - } - }, - onOpenFeedsDrawer = onOpenFeedsDrawer, - onCompose = onCompose, + // Reserve space for the header card that floats above + // Reserve space for the header card that floats above. + // When search is expanded, the card grows — add more margin. + val headerSpacerHeight by animateDpAsState( + targetValue = if (searchActive) 300.dp else 60.dp, + animationSpec = tween(200), ) - - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(headerSpacerHeight)) // Feed content based on FeedState when (val state = feedState) { @@ -653,7 +682,7 @@ fun FeedScreen( val sidePadding = LocalReadingSidePadding.current LazyColumn( state = lazyListState, - contentPadding = PaddingValues(horizontal = sidePadding + 12.dp), + contentPadding = PaddingValues(start = sidePadding + 12.dp, end = sidePadding + 12.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(loadedState.list, key = { it.idHex }) { note -> @@ -721,6 +750,53 @@ fun FeedScreen( ) } + // Layer 2: Search scrim — dims feed content when search is expanded + if (searchActive) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + onSearchActiveChange(false) + }, + ) + } + + // Layer 3: Header card — rendered AFTER scrim so it floats above it + FeedTabsHeader( + feedMode = feedMode, + activeFeedId = activeFeedId, + searchExpanded = searchActive, + onSearchExpandedChange = onSearchActiveChange, + onFeedModeChange = { mode -> + feedMode = mode + activeFeedId = null + activeFeedSource = null + if (mode != FeedMode.CUSTOM) { + DesktopPreferences.feedMode = mode + } + }, + onNavigateToFeed = { feed -> + val source = feed.source + if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) { + activeFeedId = feed.id + activeFeedSource = source + feedMode = FeedMode.CUSTOM + } + }, + onOpenFeedsDrawer = onOpenFeedsDrawer, + onCompose = onCompose, + onSearchClick = openFullSearch, + relayManager = relayManager, + localCache = localCache, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + ) + // Lightbox overlay lightboxState?.let { state -> LightboxOverlay( @@ -747,77 +823,447 @@ fun FeedScreen( private fun FeedTabsHeader( feedMode: FeedMode, activeFeedId: String? = null, + searchExpanded: Boolean = false, + onSearchExpandedChange: (Boolean) -> Unit = {}, onFeedModeChange: (FeedMode) -> Unit, onNavigateToFeed: (com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition) -> Unit = {}, onOpenFeedsDrawer: () -> Unit, onCompose: () -> Unit, + onSearchClick: () -> Unit = {}, + relayManager: DesktopRelayConnectionManager? = null, + localCache: DesktopLocalCache? = null, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, ) { val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState() val sidePadding = LocalReadingSidePadding.current + val scope = rememberCoroutineScope() + val searchState = remember { AdvancedSearchBarState(scope) } + var searchText by remember { mutableStateOf(TextFieldValue("")) } + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val debouncedQuery by searchState.debouncedQuery.collectAsState() + val relayCategories = LocalRelayCategories.current + val searchRelays by relayCategories.searchRelays.collectAsState() - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = sidePadding + 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - // Pinned feed tabs - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - pinnedFeeds.forEach { feed -> - val isSelected = - when (feed.source) { - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> { - feedMode == FeedMode.FOLLOWING - } + // Sync search text to AdvancedSearchBarState + LaunchedEffect(searchText.text) { + searchState.updateFromText(searchText.text) + } - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> { - feedMode == FeedMode.GLOBAL - } + // Auto-save to history after 1s of no typing (separate from relay debounce) + LaunchedEffect(searchText.text) { + if (searchText.text.isNotBlank()) { + kotlinx.coroutines.delay(1000L) + val query = searchState.query.value + if (!query.isEmpty) { + SearchHistoryStore.addToHistory(query) + } + } + } - else -> { - activeFeedId == feed.id + // Clear on collapse + LaunchedEffect(searchExpanded) { + if (!searchExpanded) { + searchText = TextFieldValue("") + searchState.clearSearch() + } + } + + // Auto-focus when expanded + LaunchedEffect(searchExpanded) { + if (searchExpanded) { + focusRequester.requestFocus() + } + } + + // Start/stop relay search when debounced query changes + LaunchedEffect(debouncedQuery) { + if (!debouncedQuery.isEmpty) { + searchState.clearResults() + searchState.initRelayStates(searchRelays) + searchState.startSearching("people-search") + searchState.startSearching("adv-search") + kotlinx.coroutines.delay(10_000L) + searchState.timeoutWaitingRelays() + } + } + + // NIP-50 people search subscription + if (relayManager != null) { + rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) { + if (searchRelays.isEmpty() || debouncedQuery.isEmpty) { + return@rememberSubscription null + } + createSearchPeopleSubscription( + relays = searchRelays, + searchQuery = debouncedQuery.text.ifBlank { QuerySerializer.serialize(debouncedQuery) }, + limit = 10, + onEvent = { event, _, relay, _ -> + if (searchState.trackRelayEvent(relay.url, event.id)) { + if (event is MetadataEvent) { + localCache?.consumeMetadata(event) + val user = localCache?.getUserIfExists(event.pubKey) + if (user != null) { + searchState.addPeopleResult(user) + } } } - FilterChip( - selected = isSelected, - onClick = { - when (feed.source) { - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> { - onFeedModeChange(FeedMode.FOLLOWING) - } - - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> { - onFeedModeChange(FeedMode.GLOBAL) - } - - else -> { - onNavigateToFeed(feed) - } - } - }, - label = { Text("${feed.emoji} ${feed.name}") }, - ) - } - // "Show More +" button - FilterChip( - selected = false, - onClick = onOpenFeedsDrawer, - label = { Text("+ More") }, + }, + onEose = { relay, _ -> + searchState.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED) + searchState.stopSearching("people-search") + }, + onClosed = { relay, _, _ -> + searchState.updateRelayState(relay.url, RelaySyncStatus.FAILED) + searchState.stopSearching("people-search") + }, ) } - // Compose button - IconButton(onClick = onCompose) { - Icon( - MaterialSymbols.Edit, - contentDescription = "Compose", - modifier = Modifier.size(20.dp), + // NIP-50 note search subscription + rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) { + if (searchRelays.isEmpty() || debouncedQuery.isEmpty) { + return@rememberSubscription null + } + val filters = SearchFilterFactory.createFilters(debouncedQuery) + if (filters.isEmpty()) return@rememberSubscription null + + SubscriptionConfig( + subId = generateSubId("inline-search"), + filters = filters, + relays = searchRelays, + onEvent = { event, _, relay, _ -> + if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig + if (searchState.trackRelayEvent(relay.url, event.id)) { + val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) + if (filtered.isNotEmpty()) { + searchState.addNoteResults(filtered) + } + } + }, + onEose = { relay, _ -> + searchState.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED) + searchState.stopSearching("adv-search") + }, + onClosed = { relay, _, _ -> + searchState.updateRelayState(relay.url, RelaySyncStatus.FAILED) + searchState.stopSearching("adv-search") + }, ) } } + + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = sidePadding + 8.dp, vertical = 8.dp), + ) { + Column { + // Always-visible header row: tabs + search + compose + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Feed tabs — always visible. Clicking collapses search. + pinnedFeeds.forEach { feed -> + val isSelected = + when (feed.source) { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> + feedMode == FeedMode.FOLLOWING + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> + feedMode == FeedMode.GLOBAL + else -> activeFeedId == feed.id + } + FilterChip( + selected = isSelected, + onClick = { + onSearchExpandedChange(false) + when (feed.source) { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> + onFeedModeChange(FeedMode.FOLLOWING) + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> + onFeedModeChange(FeedMode.GLOBAL) + else -> onNavigateToFeed(feed) + } + }, + label = { + Text( + "${feed.emoji} ${feed.name}", + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + ) + }, + ) + } + + // Search: pill when collapsed, active input when expanded + if (searchExpanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + BasicTextField( + value = searchText, + onValueChange = { searchText = it }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + onSearchExpandedChange(false) + focusManager.clearFocus() + true + } else { + false + } + }, + textStyle = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + singleLine = true, + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (searchText.text.isEmpty()) { + Text( + "Search notes, profiles, hashtags...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + innerTextField() + } + }, + ) + Spacer(Modifier.width(8.dp)) + Text( + if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } else { + com.vitorpamplona.amethyst.desktop.ui.search.SearchPill( + onClick = { onSearchExpandedChange(true) }, + modifier = Modifier.weight(1f), + ) + } + + // Compose button — always visible + IconButton(onClick = onCompose, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Compose", + modifier = Modifier.size(18.dp), + ) + } + } + + // Animated expanding section: history or search results + AnimatedVisibility( + visible = searchExpanded, + enter = expandVertically(animationSpec = tween(200)) + fadeIn(tween(200)), + exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(tween(100)), + ) { + Column { + val hasQuery = searchText.text.isNotBlank() + val isSearching by searchState.isSearching.collectAsState() + val people by searchState.peopleResults.collectAsState() + val notes by searchState.noteResults.collectAsState() + val hasResults = people.isNotEmpty() || notes.isNotEmpty() + + // Linear progress bar at the top — visible while searching + AnimatedVisibility( + visible = isSearching || (hasQuery && !hasResults), + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + if (hasQuery) { + // Results stream in as they arrive + if (hasResults) { + SearchResultsList( + state = searchState, + onNavigateToProfile = { pubkey -> + onSearchExpandedChange(false) + onNavigateToProfile(pubkey) + }, + onNavigateToThread = { noteId -> + onSearchExpandedChange(false) + onNavigateToThread(noteId) + }, + localCache = localCache, + modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(), + ) + } else if (isSearching) { + // Loading — centered in results area + Column( + modifier = Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Spacer(Modifier.height(8.dp)) + Text( + "Searching ${searchRelays.size} relays...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + // Search complete, no results + Column( + modifier = Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Spacer(Modifier.height(8.dp)) + Text( + if (searchRelays.isEmpty()) "No search relays configured" else "No results found", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // "Open full search" — always visible when there's a query + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { + onSearchExpandedChange(false) + onSearchClick() + }.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.AutoMirrored.OpenInNew, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(10.dp)) + Text("Open full search", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } + } else { + // Show search history when empty + SearchHistorySection( + onOpenFullSearch = { + onSearchExpandedChange(false) + onSearchClick() + }, + onHistoryItemClick = { text -> + searchText = TextFieldValue(text, TextRange(text.length)) + }, + ) + } + } + } + } + } +} + +@Composable +private fun SearchHistorySection( + onOpenFullSearch: () -> Unit, + onHistoryItemClick: (String) -> Unit = { }, +) { + val history by SearchHistoryStore.history.collectAsState() + val savedSearches by SearchHistoryStore.savedSearches.collectAsState() + + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + if (history.isNotEmpty()) { + Text( + "Recent", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + history.take(5).forEach { query -> + val text = + com.vitorpamplona.amethyst.commons.search.QuerySerializer + .serialize(query) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onHistoryItemClick(text) } + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon(MaterialSymbols.History, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(10.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + } + + if (savedSearches.isNotEmpty()) { + if (history.isNotEmpty()) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } + Text("Saved", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) + savedSearches.take(5).forEach { saved -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { onOpenFullSearch() }.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon(MaterialSymbols.Bookmark, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(10.dp)) + Text(saved.label, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { onOpenFullSearch() }.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Icon(MaterialSymbols.AutoMirrored.OpenInNew, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(10.dp)) + Text("Open full search", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } + } } @OptIn(ExperimentalFoundationApi::class) 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 e390b2df22..836a5cf546 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 @@ -44,13 +44,13 @@ 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.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop import com.vitorpamplona.amethyst.commons.resources.login_title +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.RelayStatus @@ -203,7 +203,7 @@ fun ConnectingRelaysScreen( Icon( MaterialSymbols.Check, contentDescription = null, - tint = Color(0xFF4CAF50), + tint = StatusGreen, modifier = Modifier.size(14.dp), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 0706f93fb6..96a44b0cee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -32,12 +33,12 @@ 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.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -68,19 +69,14 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.text.DateFormat +import java.util.Date import java.util.Locale -private val dateFormat = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault()) +// Locale-aware: en-US "May 28, 2026" · en-GB "28 May 2026" · de-DE "28.05.2026" · ja-JP "2026/05/28" +private val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) -private fun formatDate(timestamp: Long): String = - Instant - .ofEpochSecond(timestamp) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .format(dateFormat) +private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000L)) /** * Card displaying long-form content (NIP-23) with title, summary, and image. @@ -96,14 +92,15 @@ fun LongFormCard( val authorName = author.toBestDisplayName() val publishedAt = event.publishedAt() ?: event.createdAt - Card( + OutlinedCard( onClick = onClick, modifier = Modifier.fillMaxWidth(), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Column(modifier = Modifier.padding(16.dp)) { // Title 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 index cd6a13e876..a7c5742eac 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -48,6 +49,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text @@ -1139,13 +1141,14 @@ private fun PublishedHighlightCard( val articleAddress = highlight.inPostAddress() val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source" - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Column(modifier = Modifier.padding(16.dp)) { // Quoted highlight text diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt index 03bff1775f..95eff2e687 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/account/AccountSwitcherDropdown.kt @@ -99,7 +99,7 @@ fun AccountSwitcherDropdown( Box(modifier = modifier) { IconButton( onClick = { expanded = true }, - modifier = Modifier.size(48.dp), + modifier = Modifier.size(40.dp), ) { Icon( MaterialSymbols.Person, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index d6542a91d3..65c8fe7917 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -29,7 +29,6 @@ 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.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -107,7 +106,7 @@ fun LoginCard( @Suppress("DEPRECATION") PrimaryTabRow( selectedTabIndex = selectedTab, - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)), + modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small), ) { tabs.forEachIndexed { index, title -> Tab( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt index 03b6b1d48d..c6ba44c469 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt @@ -34,10 +34,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen import com.vitorpamplona.amethyst.desktop.account.LoginProgress import com.vitorpamplona.amethyst.desktop.account.RelayLoginStatus @@ -111,7 +111,7 @@ private fun StepRow(step: StepInfo) { Icon( MaterialSymbols.Check, contentDescription = null, - tint = Color(0xFF4CAF50), + tint = StatusGreen, modifier = Modifier.size(16.dp), ) } @@ -133,7 +133,7 @@ private fun StepRow(step: StepInfo) { style = MaterialTheme.typography.bodySmall, color = when (step.state) { - StepState.DONE -> Color(0xFF4CAF50) + StepState.DONE -> StatusGreen StepState.ACTIVE -> MaterialTheme.colorScheme.onSurface StepState.PENDING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) }, @@ -155,7 +155,7 @@ private fun RelayRow( Icon( MaterialSymbols.Check, contentDescription = null, - tint = Color(0xFF2196F3), + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(12.dp), ) } @@ -164,7 +164,7 @@ private fun RelayRow( Icon( MaterialSymbols.Check, contentDescription = null, - tint = Color(0xFF4CAF50), + tint = StatusGreen, modifier = Modifier.size(12.dp), ) } @@ -206,7 +206,7 @@ private fun RelayRow( } RelayLoginStatus.EVENT_SENT -> { - Color(0xFF2196F3) + MaterialTheme.colorScheme.primary } else -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt index 573d035861..c985c2e451 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -76,7 +75,7 @@ fun NewKeyWarningCard( Text( stringResource(Res.string.new_key_warning_title), style = MaterialTheme.typography.titleMedium, - color = Color.Red, + color = MaterialTheme.colorScheme.error, ) Spacer(Modifier.height(16.dp)) @@ -102,7 +101,7 @@ fun NewKeyWarningCard( Text( stringResource(Res.string.new_key_secret_label), style = MaterialTheme.typography.labelMedium, - color = Color.Red, + color = MaterialTheme.colorScheme.error, ) SelectableKeyText(secretKey) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt index 83de8bbdc0..23cbe72d88 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatFileAttachment.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -170,7 +169,7 @@ fun ChatFileAttachment( Modifier .fillMaxWidth() .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(MaterialTheme.shapes.small), contentScale = ContentScale.FillWidth, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index da094091ca..09532abae1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -203,7 +203,7 @@ fun ChatPane( target = dropTarget, ).then( if (isDragOver) { - Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp)) + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small) } else { Modifier }, @@ -657,7 +657,7 @@ private suspend fun sendWrappedReaction( @Composable private fun ReactionBar(onReaction: (String) -> Unit) { Surface( - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceVariant, shadowElevation = 2.dp, tonalElevation = 2.dp, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index aa7139cdbe..ac49cdc847 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -398,7 +398,7 @@ fun AppDrawer( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { /* consume click — prevent propagation to scrim */ }, - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, tonalElevation = 8.dp, ) { Column { @@ -579,7 +579,7 @@ private fun DrawerScreenCard( showMenu = true } }, - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, tonalElevation = if (isSelected) 8.dp else 2.dp, color = if (isSelected) { @@ -792,7 +792,7 @@ private fun WorkspaceCard( .fillMaxWidth() .padding(vertical = 4.dp) .clickable(onClick = onSwitch), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, tonalElevation = if (isActive) 8.dp else 2.dp, color = if (isActive) { @@ -869,7 +869,7 @@ private fun AddWorkspaceCard( .fillMaxWidth() .padding(vertical = 4.dp) .clickable(enabled = enabled, onClick = onClick), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, tonalElevation = 1.dp, ) { Row( @@ -935,7 +935,7 @@ private fun WorkspaceEditorDialog( WorkspaceIcons.availableNames.forEach { iName -> Surface( modifier = Modifier.size(40.dp).clickable { iconName = iName }, - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, color = if (isSelected(iName)) { MaterialTheme.colorScheme.primaryContainer diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index d1e8cf9a74..a857721427 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -56,13 +56,14 @@ fun ColumnHeader( modifier = modifier .fillMaxWidth() - .height(40.dp) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 12.dp) .pointerInput(Unit) { detectTapGestures( onDoubleTap = { onDoubleClick() }, ) - }.padding(horizontal = 8.dp), + }, verticalAlignment = Alignment.CenterVertically, ) { if (hasBackStack) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 8ce5c543e5..96058bcf88 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon @@ -51,6 +52,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce import java.awt.Cursor @Composable @@ -80,14 +83,23 @@ fun DeckLayout( val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } deckState.setAvailableWidth(availableWidthDp) - // Auto-fit columns on first composition or when available width changes significantly - LaunchedEffect(availableWidthDp, columns.size) { - val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH - val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat() - val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp) - if (diff > 20f && columns.isNotEmpty()) { - deckState.fitColumnsToWidth(availableWidthDp) - } + // Auto-fit columns when available width changes (debounced to avoid per-frame + // recomposition during sidebar expand/collapse animation) + @OptIn(FlowPreview::class) + LaunchedEffect(Unit) { + snapshotFlow { availableWidthDp to columns.size } + .debounce(300) + .collect { (width, size) -> + val dividers = (size - 1) * DeckState.DIVIDER_WIDTH + val totalColumnWidth = + deckState.columns.value + .sumOf { it.width.toDouble() } + .toFloat() + val diff = kotlin.math.abs(totalColumnWidth + dividers - width) + if (diff > 20f && size > 0) { + deckState.fitColumnsToWidth(width) + } + } } Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 6cea5f0013..34554d5e18 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -20,34 +20,89 @@ */ package com.vitorpamplona.amethyst.desktop.ui.deck +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable +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.fillMaxHeight +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.material3.IconButton +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider 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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus -import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop -import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown -import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import kotlinx.collections.immutable.ImmutableList +private val EXPANDED_WIDTH = 240.dp +private val COLLAPSED_WIDTH = 64.dp + +/** + * Data class for a sidebar navigation item. + */ +private data class NavItem( + val type: DeckColumnType, + val label: String, + val icon: MaterialSymbol, +) + +private val NAV_ITEMS = + listOf( + NavItem(DeckColumnType.HomeFeed, "Home", MaterialSymbols.Home), + NavItem(DeckColumnType.Search, "Search", MaterialSymbols.Search), + NavItem(DeckColumnType.Messages, "Messages", MaterialSymbols.Mail), + NavItem(DeckColumnType.Wallet, "Wallet", MaterialSymbols.AccountBalanceWallet), + NavItem(DeckColumnType.Bookmarks, "Bookmarks", MaterialSymbols.Bookmark), + NavItem(DeckColumnType.Settings, "Settings", MaterialSymbols.Settings), + ) + +@OptIn(ExperimentalComposeUiApi::class) @Composable -fun DeckSidebar( +fun MainSidebar( activeNpub: String?, allAccounts: ImmutableList, localCache: DesktopLocalCache?, @@ -56,67 +111,490 @@ fun DeckSidebar( onRemoveAccount: (String) -> Unit, onAddColumn: () -> Unit, onOpenSettings: () -> Unit, + onNavigate: (DeckColumnType) -> Unit, + activeColumnType: DeckColumnType?, onShowImportFollowListDialog: () -> Unit = {}, signerConnectionState: SignerConnectionState, lastPingTimeSec: Long?, torStatus: TorServiceStatus, modifier: Modifier = Modifier, ) { + var expanded by remember { mutableStateOf(true) } + + val animatedWidth by animateDpAsState( + targetValue = if (expanded) EXPANDED_WIDTH else COLLAPSED_WIDTH, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + + // Observe metadata to recompose when display names/pictures load + @Suppress("UNUSED_VARIABLE") + val metadataVersion by localCache?.metadataVersion?.collectAsState() + ?: remember { mutableStateOf(0L) } + + // Resolve user info for avatar header + val pubkeyHex = remember(activeNpub) { activeNpub?.let { decodePublicKeyAsHexOrNull(it) } } + val user = + remember(pubkeyHex, metadataVersion) { + pubkeyHex?.let { localCache?.getUserIfExists(it) } + } + val displayName = + remember(user, metadataVersion) { + user?.let { + val name = it.toBestDisplayName() + name.takeIf { n -> n != it.pubkeyDisplayHex() } + } + } + val avatarUrl = remember(user, metadataVersion) { user?.profilePicture() } + + // Custom feeds from repository + val feedRepo = LocalFeedRepository.current + val allFeeds by feedRepo.feeds.collectAsState() + Column( modifier = modifier - .width(56.dp) + .width(animatedWidth) .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceContainer) + .clipToBounds() + .background(MaterialTheme.colorScheme.surface) .padding(top = 8.dp + titleBarInsetTop, bottom = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, ) { - AccountSwitcherDropdown( + // -- Avatar + Account header -- + SidebarAccountHeader( activeNpub = activeNpub, allAccounts = allAccounts, localCache = localCache, + displayName = displayName, + avatarUrl = avatarUrl, + pubkeyHex = pubkeyHex, + expanded = expanded, onSwitchAccount = onSwitchAccount, onAddAccount = onAddAccount, onRemoveAccount = onRemoveAccount, ) - Spacer(Modifier.size(16.dp)) + Spacer(Modifier.height(8.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(8.dp)) - IconButton(onClick = onAddColumn) { - Icon( - MaterialSymbols.Add, - contentDescription = "Add Column", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + // -- Nav items (scrollable) -- + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + ) { + NAV_ITEMS.forEach { item -> + val isActive = activeColumnType?.typeKey() == item.type.typeKey() + SidebarNavItem( + icon = item.icon, + label = item.label, + isActive = isActive, + expanded = expanded, + onClick = { onNavigate(item.type) }, + ) + } + + // -- All Screens -- + Spacer(Modifier.height(8.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(8.dp)) + + SidebarNavItem( + icon = MaterialSymbols.Apps, + label = "All Screens", + isActive = false, + expanded = expanded, + onClick = onAddColumn, ) + + // -- Custom feeds section -- + if (allFeeds.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = "FEEDS", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + allFeeds.forEach { feed -> + val isActive = + activeColumnType is DeckColumnType.CustomFeed && + activeColumnType.feedId == feed.id + SidebarFeedItem( + feed = feed, + isActive = isActive, + expanded = expanded, + onClick = { + onNavigate( + DeckColumnType.CustomFeed( + feedId = feed.id, + feedName = feed.name, + feedEmoji = feed.emoji, + ), + ) + }, + ) + } + + // "+ Add Feed" button + SidebarNavItem( + icon = MaterialSymbols.Add, + label = "Add Feed", + isActive = false, + expanded = expanded, + onClick = onAddColumn, + ) + } } - IconButton(onClick = onShowImportFollowListDialog) { - Icon( - MaterialSymbols.PersonAdd, - contentDescription = "Import Follow List", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // -- Bottom section: status indicators + collapse toggle -- + HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(Modifier.height(4.dp)) - Spacer(Modifier.weight(1f)) - - BunkerHeartbeatIndicator( - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, + // Tor status — same style as nav items + val torLabel = + when (torStatus) { + is TorServiceStatus.Off -> "Tor: Off" + is TorServiceStatus.Connecting -> "Tor: Connecting" + is TorServiceStatus.Active -> "Tor: Connected" + is TorServiceStatus.Error -> "Tor: Error" + } + val torIcon = + if (torStatus is TorServiceStatus.Active) MaterialSymbols.Security else MaterialSymbols.Shield + SidebarNavItem( + icon = torIcon, + label = torLabel, + isActive = false, + expanded = expanded, + onClick = onOpenSettings, ) - Spacer(Modifier.size(4.dp)) - TorStatusIndicator(status = torStatus, onClick = onOpenSettings) + // Bunker heartbeat — same style as nav items (only show when connected) + if (signerConnectionState is SignerConnectionState.Connected) { + SidebarNavItem( + icon = MaterialSymbols.Favorite, + label = "Bunker: OK", + isActive = false, + expanded = expanded, + onClick = onOpenSettings, + ) + } - Spacer(Modifier.size(4.dp)) + Spacer(Modifier.height(4.dp)) - IconButton(onClick = onOpenSettings) { - Icon( - MaterialSymbols.Settings, - contentDescription = "Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + // Collapse toggle + SidebarNavItem( + icon = if (expanded) MaterialSymbols.AutoMirrored.KeyboardArrowLeft else MaterialSymbols.ChevronRight, + label = if (expanded) "Collapse" else "Expand", + isActive = false, + expanded = expanded, + onClick = { + expanded = !expanded + DesktopPreferences.sidebarCollapsed = !expanded + }, + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SidebarAccountHeader( + activeNpub: String?, + allAccounts: ImmutableList, + localCache: DesktopLocalCache?, + displayName: String?, + avatarUrl: String?, + pubkeyHex: String?, + expanded: Boolean, + onSwitchAccount: (String) -> Unit, + onAddAccount: () -> Unit, + onRemoveAccount: (String) -> Unit, +) { + var showDropdown by remember { mutableStateOf(false) } + var isHovered by remember { mutableStateOf(false) } + + val hoverBg = + if (isHovered) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + } else { + Color.Transparent + } + + Box { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(MaterialTheme.shapes.small) + .clickable { showDropdown = true } + .background(hoverBg) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Avatar + if (pubkeyHex != null) { + UserAvatar( + userHex = pubkeyHex, + pictureUrl = avatarUrl, + size = if (expanded) 32.dp else 28.dp, + contentDescription = "Account avatar", + ) + } else { + Box( + modifier = + Modifier + .size(if (expanded) 32.dp else 28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + MaterialSymbols.Person, + contentDescription = "Account", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + + if (expanded) { + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayName ?: "Account", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (activeNpub != null) { + Text( + text = npubShortForSidebar(activeNpub), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + + // Account switcher dropdown menu + DropdownMenu( + expanded = showDropdown, + onDismissRequest = { showDropdown = false }, + offset = DpOffset(x = if (expanded) 240.dp else 56.dp, y = 0.dp), + ) { + allAccounts.forEach { account -> + val isActive = account.npub == activeNpub + DropdownMenuItem( + text = { + Text( + text = resolveDisplayNameForSidebar(account.npub, localCache) ?: npubShortForSidebar(account.npub), + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + ) + }, + onClick = { + showDropdown = false + onSwitchAccount(account.npub) + }, + trailingIcon = { + if (isActive) { + Icon(MaterialSymbols.Check, contentDescription = "Active", modifier = Modifier.size(16.dp)) + } + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("Add Account") }, + onClick = { + showDropdown = false + onAddAccount() + }, + leadingIcon = { Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(16.dp)) }, ) } } } + +/** + * A single navigation item row with icon + optional label. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SidebarNavItem( + icon: MaterialSymbol, + label: String, + isActive: Boolean, + expanded: Boolean, + onClick: () -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + + val backgroundColor = + when { + isActive -> MaterialTheme.colorScheme.primaryContainer + isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else -> MaterialTheme.colorScheme.surface + } + + val iconTint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + val textColor = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .background(backgroundColor) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = if (!expanded) label else null, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp), + ) + } + } +} + +/** + * A custom feed item in the sidebar. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SidebarFeedItem( + feed: FeedDefinition, + isActive: Boolean, + expanded: Boolean, + onClick: () -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + + val backgroundColor = + when { + isActive -> MaterialTheme.colorScheme.primaryContainer + isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else -> MaterialTheme.colorScheme.surface + } + + val iconTint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + val textColor = + if (isActive) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .background(backgroundColor) + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (feed.emoji.isNotEmpty() && expanded) { + Text( + text = feed.emoji, + modifier = Modifier.size(24.dp), + style = MaterialTheme.typography.titleMedium, + ) + } else { + Icon( + MaterialSymbols.AutoMirrored.Feed, + contentDescription = if (!expanded) feed.name else null, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), + ) { + Text( + text = feed.name.ifEmpty { "Feed" }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 12.dp), + ) + } + } +} + +private fun npubShortForSidebar(npub: String): String { + if (npub.length <= 20) return npub + return "${npub.take(10)}...${npub.takeLast(6)}" +} + +private fun resolveDisplayNameForSidebar( + npub: String, + localCache: DesktopLocalCache?, +): String? { + if (localCache == null) return null + val pubkeyHex = + com.vitorpamplona.quartz.nip19Bech32 + .decodePublicKeyAsHexOrNull(npub) ?: return null + val user = localCache.getUserIfExists(pubkeyHex) ?: return null + val name = user.toBestDisplayName() + return name.takeIf { it != user.pubkeyDisplayHex() } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt index bcfc46cd52..9613b37164 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt @@ -33,7 +33,6 @@ 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.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -302,7 +301,7 @@ private fun FeedRow( Modifier .fillMaxWidth() .clickable(onClick = onSelect), - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, tonalElevation = 1.dp, ) { Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 9e7fb7d7d9..922f69f934 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -21,60 +21,30 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight 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.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider 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.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher -import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback -import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown -import com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog -import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator -import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen -import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState -import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun SinglePaneLayout( @@ -107,185 +77,10 @@ fun SinglePaneLayout( val navStack by navState.stack.collectAsState() val currentOverlay = navStack.lastOrNull() - val isImmersive by LocalIsImmersiveFullscreen.current - - Row(modifier = modifier.fillMaxSize()) { - if (!isImmersive) { - // Custom navigation rail with a scrollable items area so all pinned - // screens (Home, Reads, Notifications, ...) remain reachable when - // the window is short. Bottom status indicators stay anchored. - // - // We don't use Material3 `NavigationRail` directly here because its - // internal `Column` is not scrollable — when there are more pinned - // items than fit vertically, items at the bottom of the list (and - // the "More" button) get clipped on small windows. We replicate the - // rail's container styling via NavigationRailItemDefaults so item - // visuals are unchanged. - val railScrollState = rememberScrollState() - val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState() - val torState = LocalTorState.current - val allAccountsState by accountManager.allAccounts.collectAsState() - val singlePaneScope = rememberCoroutineScope() - var showAddAccountDialog by remember { mutableStateOf(false) } - - Column( - modifier = - Modifier - .width(80.dp) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceContainer), - horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, - ) { - // macOS: push rail items below the traffic lights. - Spacer(Modifier.height(titleBarInsetTop)) - - // Scrollable region for pinned screens + "More" launcher. - // Takes all remaining space above the fixed bottom controls - // (weight(1f)) and scrolls vertically when items overflow. - Column( - modifier = - Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(railScrollState), - horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - ) { - pinnedScreens.forEach { screenType -> - // Rename "Home" to "Feeds" in the nav rail - val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title() - NavigationRailItem( - selected = currentColumnType == screenType && navStack.isEmpty(), - onClick = { - singlePaneState.navigate(screenType) - navState.clear() - }, - icon = { - Icon( - screenType.icon(), - contentDescription = label, - modifier = Modifier.size(22.dp), - ) - }, - label = { - Text( - label, - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - colors = NavigationRailItemDefaults.colors(), - ) - } - - NavigationRailItem( - selected = false, - onClick = onShowImportFollowListDialog, - icon = { - Icon( - MaterialSymbols.PersonAdd, - contentDescription = "Import Follow List", - modifier = Modifier.size(22.dp), - ) - }, - label = { - Text( - "Import", - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - ) - }, - colors = NavigationRailItemDefaults.colors(), - ) - - NavigationRailItem( - selected = false, - onClick = onOpenAppDrawer, - icon = { - Icon( - MaterialSymbols.Apps, - contentDescription = "App Drawer", - modifier = Modifier.size(22.dp), - ) - }, - label = { - Text( - "More", - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - ) - }, - colors = NavigationRailItemDefaults.colors(), - ) - } - - // Fixed bottom controls — always visible regardless of how - // many items are in the scrollable region above. - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, - ) { - // Relay health — shows elapsed time since last event (hidden when <30s) - RelayHealthIndicator( - lastEventReceivedAt = lastRelayEventAt, - modifier = Modifier.padding(bottom = 4.dp), - ) - - BunkerHeartbeatIndicator( - signerConnectionState = signerConnectionState, - lastPingTimeSec = lastPingTimeSec, - modifier = Modifier.padding(bottom = 4.dp), - ) - - TorStatusIndicator( - status = torState.status, - onClick = { - singlePaneState.navigate(DeckColumnType.Settings) - navState.clear() - }, - modifier = Modifier.padding(bottom = 4.dp), - ) - - AccountSwitcherDropdown( - activeNpub = accountManager.currentAccount()?.npub, - allAccounts = allAccountsState, - localCache = localCache, - onSwitchAccount = { npub -> - singlePaneScope.launch(Dispatchers.IO) { - accountManager.switchAccount(npub) - } - }, - onAddAccount = { showAddAccountDialog = true }, - onRemoveAccount = { npub -> - singlePaneScope.launch(Dispatchers.IO) { - accountManager.removeAccountFromStorage(npub) - } - }, - modifier = Modifier.padding(bottom = 8.dp), - ) - } - } - - if (showAddAccountDialog) { - AddAccountDialog( - accountManager = accountManager, - onDismiss = { showAddAccountDialog = false }, - onAccountAdded = { - showAddAccountDialog = false - singlePaneScope.launch(Dispatchers.IO) { - accountManager.refreshAccountList() - } - }, - ) - } - } - - if (!isImmersive) { - VerticalDivider() - } - - Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + // Sidebar is now provided by Main.kt (shared MainSidebar for both layout modes). + // SinglePaneLayout only renders the content pane. + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { // Offline banner — shows when no remote relays connected val connectedRelays by relayManager.connectedRelays.collectAsState() val localRelay = LocalLocalRelayStore.current diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index 8fbc168f55..ec538790d4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui.highlights +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -34,10 +35,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -58,9 +59,9 @@ import com.vitorpamplona.amethyst.commons.model.highlights.HighlightData import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import kotlinx.coroutines.launch -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter +import java.text.DateFormat +import java.util.Date +import java.util.Locale @Composable fun MyHighlightsScreen( @@ -175,13 +176,14 @@ private fun HighlightCard( highlight: HighlightData, onDelete: () -> Unit, ) { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), colors = - CardDefaults.cardColors( + CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surface, ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, ) { Row( modifier = Modifier.padding(16.dp).fillMaxWidth(), @@ -236,8 +238,8 @@ private fun HighlightCard( } } -private fun formatTimestamp(epochSeconds: Long): String = - Instant - .ofEpochSecond(epochSeconds) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) +// Locale-aware date+time, e.g. en-US "May 28, 2026 2:32 PM" · de-DE "28.05.2026 14:32". +private val dateTimeFormat = + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) + +private fun formatTimestamp(epochSeconds: Long): String = dateTimeFormat.format(Date(epochSeconds * 1000L)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt index ce757e85c6..9d8f2b4771 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider @@ -59,7 +58,7 @@ fun AudioPlayer( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index ae3b082bcb..a71c4024df 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -111,7 +111,7 @@ fun DesktopVideoPlayer( .height(constrainedHeight) .background( MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(8.dp), + MaterialTheme.shapes.small, ), contentAlignment = Alignment.Center, ) { @@ -123,7 +123,7 @@ fun DesktopVideoPlayer( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(8.dp)), + .clip(MaterialTheme.shapes.small), contentScale = ContentScale.Fit, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt index 44d2a20157..5032375b28 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/LightboxOverlay.kt @@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton @@ -421,7 +420,7 @@ fun LightboxOverlay( Modifier .align(Alignment.BottomCenter) .padding(16.dp) - .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) + .background(Color.Black.copy(alpha = 0.5f), MaterialTheme.shapes.large) .padding(horizontal = 16.dp, vertical = 6.dp), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt index ba2159ccca..59eb0475b6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/PictureDisplay.kt @@ -72,7 +72,7 @@ fun PictureDisplay( .heightIn(max = 500.dp) .clip( if (index == 0 && title == null && description.isBlank()) { - RoundedCornerShape(8.dp) + MaterialTheme.shapes.small } else if (index == 0) { RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) } else { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt index 9039978f28..c0dff43f36 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichTextViewer.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme @@ -171,7 +170,7 @@ fun DesktopRichTextViewer( Modifier .weight(1f) .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .then( if (callbacks.onImageClick != null) { Modifier.clickable { callbacks.onImageClick.invoke(urls, index) } @@ -349,7 +348,7 @@ private fun RenderSegment( Modifier .fillMaxWidth() .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(MaterialTheme.shapes.small), contentScale = ContentScale.Fit, ) } @@ -362,7 +361,7 @@ private fun RenderSegment( Modifier .fillMaxWidth() .heightIn(max = 300.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(MaterialTheme.shapes.small), contentScale = ContentScale.Fit, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 7b152971ec..3529fbed08 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui.note +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -36,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -147,10 +149,15 @@ fun NoteCard( // the NoteActionsRow have their own clickables that consume the click before // it reaches the Card's handler, so tapping an action still fires only that // action. - val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) - val cardElevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + val outlineVariant = MaterialTheme.colorScheme.outlineVariant + val cardBorder = + remember(outlineVariant) { + BorderStroke(1.dp, outlineVariant) + } + val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface) + val cardShape = MaterialTheme.shapes.medium val cardBody: @Composable ColumnScope.() -> Unit = { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.padding(16.dp)) { Column { Row( modifier = Modifier.fillMaxWidth(), @@ -230,7 +237,7 @@ fun NoteCard( Modifier .fillMaxWidth() .heightIn(max = maxMediaHeight) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .then( if (onImageClick != null) { Modifier.clickable { onImageClick(imageUrls, index) } @@ -302,18 +309,20 @@ fun NoteCard( } if (onClick != null) { - Card( + OutlinedCard( onClick = onClick, - modifier = modifier.fillMaxWidth(), + modifier = modifier, colors = cardColors, - elevation = cardElevation, + border = cardBorder, + shape = cardShape, content = cardBody, ) } else { - Card( - modifier = modifier.fillMaxWidth(), + OutlinedCard( + modifier = modifier, colors = cardColors, - elevation = cardElevation, + border = cardBorder, + shape = cardShape, content = cardBody, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 3d174db984..6962ea6512 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -528,7 +527,7 @@ fun EditProfileContent( Modifier .fillMaxWidth() .height(120.dp) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .border( width = if (isBannerDragOver) 2.dp else 1.dp, color = @@ -537,7 +536,7 @@ fun EditProfileContent( } else { MaterialTheme.colorScheme.outline }, - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.small, ).clickable { onPickBanner() } .dragAndDropTarget( shouldStartDragAndDrop = { true }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileInfoCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileInfoCard.kt index 95da5b8d24..8dbc844c57 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileInfoCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileInfoCard.kt @@ -31,8 +31,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber /** * Card displaying Nostr account key information. @@ -57,7 +57,7 @@ fun ProfileInfoCard( Text( "Read-only mode", style = MaterialTheme.typography.labelMedium, - color = Color.Yellow, + color = StatusAmber, ) Spacer(Modifier.height(8.dp)) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt index e23da3f123..175a179ff9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayStatusCard.kt @@ -35,11 +35,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen +import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed import com.vitorpamplona.amethyst.desktop.network.RelayStatus import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion @@ -73,9 +74,9 @@ fun RelayStatusCard( ) { val statusColor = when { - status.connected -> Color.Green - status.error != null -> Color.Red - else -> Color.Gray + status.connected -> StatusGreen + status.error != null -> StatusRed + else -> MaterialTheme.colorScheme.onSurfaceVariant } if (status.connected) { @@ -114,9 +115,9 @@ fun RelayStatusCard( val torState = LocalTorState.current val badgeColor = if (torState.status is TorServiceStatus.Off) { - Color(0xFFF44336) // Red — Tor required but off + StatusRed // Tor required but off } else { - Color(0xFF4CAF50) // Green — routed via Tor + StatusGreen // routed via Tor } val badgeText = if (torState.status is TorServiceStatus.Off) "Requires Tor" else "via Tor" @@ -138,7 +139,7 @@ fun RelayStatusCard( Text( error, style = MaterialTheme.typography.bodySmall, - color = Color.Red.copy(alpha = 0.8f), + color = MaterialTheme.colorScheme.error, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt index 15a0e5412e..e34e821565 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt @@ -30,7 +30,6 @@ 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.shape.RoundedCornerShape import androidx.compose.material3.AssistChip import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -79,7 +78,7 @@ fun AdvancedSearchPanel( ) { Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt new file mode 100644 index 0000000000..c2751a6a04 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.search + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo +import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight + +@Composable +fun SearchPill( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shortcutHint = if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F" + Surface( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = modifier.height(36.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.hoverHighlight().padding(horizontal = 12.dp), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = "Search", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Search...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Text( + shortcutHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSpotlight.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSpotlight.kt new file mode 100644 index 0000000000..ade27c48f7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSpotlight.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR 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.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.search.QuerySerializer +import com.vitorpamplona.amethyst.desktop.SearchHistoryStore +import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight + +@Composable +fun SearchSpotlight( + onSelectProfile: (String) -> Unit, + onSelectNote: (String) -> Unit, + onSelectHashtag: (String) -> Unit, + onOpenFullSearch: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } + + val history by SearchHistoryStore.history.collectAsState() + val savedSearches by SearchHistoryStore.savedSearches.collectAsState() + + val hasQuery = textFieldValue.text.isNotBlank() + + Dialog(onDismissRequest = onDismiss) { + // Full-screen scrim + centered card + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + onDismiss() + }, + contentAlignment = Alignment.TopCenter, + ) { + // Search card — offset 15% from top + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp, + modifier = + Modifier + .padding(top = 80.dp) + .widthIn(max = 600.dp) + .fillMaxWidth(0.9f) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + // consume clicks so they don't dismiss + }, + ) { + Column( + modifier = + Modifier + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + onDismiss() + true + } else { + false + } + }, + ) { + // Search input + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + BasicTextField( + value = textFieldValue, + onValueChange = { textFieldValue = it }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester), + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + singleLine = true, + decorationBox = { innerTextField -> + Box { + if (textFieldValue.text.isEmpty()) { + Text( + "Search notes, profiles, hashtags...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + innerTextField() + } + }, + ) + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + // Results or history + LazyColumn( + modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(), + ) { + if (!hasQuery) { + // Recent searches + if (history.isNotEmpty()) { + item { + Text( + "Recent", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp, 12.dp, 16.dp, 4.dp), + ) + } + items(history.take(5)) { query -> + val text = QuerySerializer.serialize(query) + SpotlightRow( + icon = { Icon(MaterialSymbols.History, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + text = text, + onClick = { + textFieldValue = TextFieldValue(text, TextRange(text.length)) + }, + ) + } + } + + // Saved searches + if (savedSearches.isNotEmpty()) { + item { + Text( + "Saved", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp, 12.dp, 16.dp, 4.dp), + ) + } + items(savedSearches.take(5)) { saved -> + SpotlightRow( + icon = { Icon(MaterialSymbols.Bookmark, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) }, + text = saved.label, + onClick = { onOpenFullSearch(QuerySerializer.serialize(saved.query)) }, + ) + } + } + + if (history.isEmpty() && savedSearches.isEmpty()) { + item { + Text( + "Type to search notes, profiles, and hashtags", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp), + ) + } + } + } else { + // Placeholder for live results — Phase 2 will wire AdvancedSearchBarState + item { + SpotlightRow( + icon = { Icon(MaterialSymbols.Search, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + text = "Search for \"${textFieldValue.text}\"", + onClick = { onOpenFullSearch(textFieldValue.text) }, + ) + } + + item { + SpotlightRow( + icon = { Icon(MaterialSymbols.Tag, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + text = "#${textFieldValue.text.removePrefix("#")}", + onClick = { onSelectHashtag(textFieldValue.text.removePrefix("#")) }, + ) + } + + // "Open full search" at bottom + item { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + SpotlightRow( + icon = { + Icon( + MaterialSymbols.AutoMirrored.OpenInNew, + null, + Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + text = "Open full search", + textColor = MaterialTheme.colorScheme.primary, + onClick = { onOpenFullSearch(textFieldValue.text) }, + ) + } + } + } + } + } + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +private fun SpotlightRow( + icon: @Composable () -> Unit, + text: String, + textColor: Color = MaterialTheme.colorScheme.onSurface, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .hoverHighlight() + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + icon() + Spacer(Modifier.width(12.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = textColor, + maxLines = 1, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt index 82f4c782d1..69c273472d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -57,10 +57,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen +import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -263,9 +264,9 @@ private fun ServerRow( shape = CircleShape, color = when (status) { - ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50) - ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336) - ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E) + ServerHealthCheck.ServerStatus.ONLINE -> StatusGreen + ServerHealthCheck.ServerStatus.OFFLINE -> StatusRed + ServerHealthCheck.ServerStatus.UNKNOWN -> MaterialTheme.colorScheme.onSurfaceVariant }, ) {} } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt new file mode 100644 index 0000000000..d1a47ead20 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/AmethystSpacing.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class AmethystSpacing( + val xxs: Dp = 2.dp, + val xs: Dp = 4.dp, + val sm: Dp = 8.dp, + val md: Dp = 12.dp, + val lg: Dp = 16.dp, + val xl: Dp = 24.dp, + val xxl: Dp = 32.dp, + val cardPadding: Dp = 16.dp, + val cardGap: Dp = 8.dp, + val sidebarExpandedWidth: Dp = 240.dp, + val sidebarCollapsedWidth: Dp = 56.dp, + val columnHeaderHeight: Dp = 48.dp, +) + +val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() } + +val LocalIsDarkTheme = staticCompositionLocalOf { false } + +/** Mutable state for Cmd+F feed search. Provided at Main.kt level. */ +val LocalFeedSearchActive = compositionLocalOf { mutableStateOf(false) } + +/** Callback to open the full Search column/screen. Provided at Main.kt level. */ +val LocalOpenFullSearch = compositionLocalOf<() -> Unit> { {} } + +val MaterialTheme.spacing: AmethystSpacing + @Composable @ReadOnlyComposable + get() = LocalSpacing.current diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.kt new file mode 100644 index 0000000000..eb6858691a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/theme/HoverModifiers.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent + +/** + * Adds a subtle background highlight on hover. The state read is deferred to + * the draw phase via [drawBehind], so hover enter/exit only invalidates draw — + * no recomposition of the host composable. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun Modifier.hoverHighlight(hoverColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)): Modifier { + val color = remember { mutableStateOf(Color.Transparent) } + return this + .onPointerEvent(PointerEventType.Enter) { color.value = hoverColor } + .onPointerEvent(PointerEventType.Exit) { color.value = Color.Transparent } + .drawBehind { drawRect(color.value) } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt index 84fe2a1d63..89a3c2b284 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt @@ -33,12 +33,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber +import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen +import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed /** * Small shield icon showing Tor connection status in the sidebar footer. @@ -54,19 +56,19 @@ fun TorStatusIndicator( val (icon, tint, tooltip) = when (status) { is TorServiceStatus.Off -> { - Triple(MaterialSymbols.Shield, Color.Gray, "Tor: Off") + Triple(MaterialSymbols.Shield, MaterialTheme.colorScheme.onSurfaceVariant, "Tor: Off") } is TorServiceStatus.Connecting -> { - Triple(MaterialSymbols.Shield, Color(0xFFFFB300), "Tor: Connecting...") + Triple(MaterialSymbols.Shield, StatusAmber, "Tor: Connecting...") } is TorServiceStatus.Active -> { - Triple(MaterialSymbols.Shield, Color(0xFF4CAF50), "Tor: Connected") + Triple(MaterialSymbols.Shield, StatusGreen, "Tor: Connected") } is TorServiceStatus.Error -> { - Triple(MaterialSymbols.Shield, Color(0xFFF44336), "Tor: ${status.message}") + Triple(MaterialSymbols.Shield, StatusRed, "Tor: ${status.message}") } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt index 7b11e0e50e..eb2c16e705 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -631,7 +630,7 @@ private fun SendDialog( Dialog(onDismissRequest = { if (!isLoading) onDismiss() }) { Card( modifier = Modifier.width(480.dp), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, ) { Column(modifier = Modifier.padding(24.dp)) { // Header @@ -849,7 +848,7 @@ private fun ReceiveDialog( Dialog(onDismissRequest = { if (!isGenerating) onDismiss() }) { Card( modifier = Modifier.width(400.dp), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, ) { Column(modifier = Modifier.padding(24.dp)) { // Header: title + close X diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt index f9adf3bff9..6734d49793 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt @@ -94,12 +94,12 @@ class AccountManagerStateTransitionTest { advanceUntilIdle() - // Should see: LoggedOut → ConnectingRelays → LoggedIn + // Should see: Loading → ConnectingRelays → LoggedIn assertTrue( states.size >= 3, "Expected at least 3 state transitions, got ${states.size}: $states", ) - assertIs(states[0]) + assertIs(states[0]) assertIs(states[1]) assertIs(states[2]) @@ -125,12 +125,12 @@ class AccountManagerStateTransitionTest { advanceUntilIdle() - // Should see: LoggedOut → ConnectingRelays → LoggedOut + // Should see: Loading → ConnectingRelays → LoggedOut assertTrue( states.size >= 3, "Expected at least 3 state transitions, got ${states.size}: $states", ) - assertIs(states[0]) + assertIs(states[0]) assertIs(states[1]) assertIs(states[2]) diff --git a/docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md b/docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md new file mode 100644 index 0000000000..3f80a65e19 --- /dev/null +++ b/docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md @@ -0,0 +1,220 @@ +--- +title: "fix: macOS bundled VLC discovery broken by versioned setenv symbol" +type: fix +status: active +date: 2026-05-18 +deepened: 2026-05-18 +origin: docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md +--- + +# fix: macOS bundled VLC discovery broken by versioned setenv symbol + +## Enhancement Summary + +**Deepened on:** 2026-05-18 +**Research agents used:** VLC --plugin-path audit, macOS JNA symbol research, VlcjPlayerPool init flow audit + +### Key Improvements from Research +1. `--plugin-path` factory arg is unreliable — VLC needs `VLC_PLUGIN_PATH` env var set before `libvlc_new()` +2. `--reset-plugins-cache` is VLC CLI-only, not available via libvlc/vlcj factory args +3. Simpler fix: replace `LibC.INSTANCE.setenv()` with JNA-free env var setting + +## Overview + +Bundled VLC video playback is broken on macOS release builds. +`MacOsVlcDiscoverer.setPluginPath()` calls `LibC.INSTANCE.setenv()` via JNA, +but macOS 13+ uses versioned C symbols (`setenv$3b99ba0d`) that JNA's `dlsym` +cannot resolve. Without system VLC installed, video is completely unavailable. + +(See brainstorm: `docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md`) + +## Problem Statement + +``` +VLC: bundled discovery threw Error looking up function 'setenv$3b99ba0d': + dlsym(0x..., setenv$3b99ba0d): symbol not found +VLC: init failed — ...DiscoveryDirectoryProvider: Provider ...not found +``` + +- Affects all macOS DMG users without system VLC.app installed +- Audio playback works (separate factory, no plugin path needed for audio codecs) +- Graceful degradation exists (shows "Install VLC" message) but shouldn't be needed + +## Proposed Solution (Updated from Research) + +**Replace `LibC.INSTANCE.setenv()` with a JNA-free approach to set the env var.** + +VLC requires `VLC_PLUGIN_PATH` as a process-level environment variable BEFORE +`libvlc_new()` (called by `MediaPlayerFactory`). The `--plugin-path` factory arg +is NOT reliably forwarded to the plugin scanner. So we must set the actual env var, +just without using JNA's broken `LibC.setenv` binding. + +### Approach: Use JNA's lower-level Native.getLibrary to call setenv directly + +Instead of `LibC.INSTANCE.setenv()` which goes through vlcj's `LibC` interface +binding (which triggers the versioned symbol lookup), use JNA's `Function.getFunction` +to call `setenv` from libc directly without the problematic interface mapping: + +```kotlin +override fun setPluginPath(pluginPath: String?): Boolean { + if (pluginPath == null) return false + return try { + // Call setenv directly via JNA Function API, bypassing LibC interface + // which fails on macOS 13+ due to versioned symbol lookup + val setenv = com.sun.jna.Function.getFunction("c", "setenv") + setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0 + } catch (e: Throwable) { + // Fallback: set as JVM system property for factory arg approach + System.setProperty("vlc.plugin.path", pluginPath) + true + } +} +``` + +**If that still hits the symbol issue**, the alternative fallback is: + +```kotlin +override fun setPluginPath(pluginPath: String?): Boolean { + if (pluginPath == null) return false + // Store for VlcjPlayerPool to pass as --plugin-path factory arg + discoveredPluginPath = pluginPath + return true +} +``` + +Then in `VlcjPlayerPool.init()`, pass `--plugin-path` to both factories as belt-and-suspenders. + +### Plugin Cache Fix + +Since `--reset-plugins-cache` is VLC CLI-only (not available via libvlc), fix stale +cache by deleting the cache file before factory creation: + +```kotlin +// Delete stale VLC plugin cache before factory init +val cacheDir = File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc") +cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() } +``` + +## Implementation + +### File 1: `MacOsVlcDiscoverer.kt` + +**Current** (line 55): +```kotlin +override fun setPluginPath(pluginPath: String?): Boolean = + LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0 +``` + +**Change to:** +```kotlin +var discoveredPluginPath: String? = null + private set + +override fun setPluginPath(pluginPath: String?): Boolean { + if (pluginPath == null) return false + discoveredPluginPath = pluginPath + return try { + // Direct JNA Function call bypasses vlcj's LibC interface binding + // which fails on macOS 13+ (versioned symbol setenv$3b99ba0d) + val setenv = com.sun.jna.Function.getFunction("c", "setenv") + setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0 + } catch (_: Throwable) { + // If JNA call fails, store path for --plugin-path fallback + false + } +} +``` + +Remove import: `uk.co.caprica.vlcj.binding.lib.LibC` + +### File 2: `VlcjPlayerPool.kt` + +**Changes to `init()` (lines 77-113):** + +1. Hold reference to `MacOsVlcDiscoverer` for plugin path access: +```kotlin +val macOsDiscoverer = MacOsVlcDiscoverer() +val nd = NativeDiscovery(BundledVlcDiscoverer(), macOsDiscoverer) +``` + +2. Build factory args with plugin path fallback: +```kotlin +val factoryArgs = mutableListOf("--no-xlib") + +// If setenv failed, pass --plugin-path as fallback +val pluginPath = macOsDiscoverer.discoveredPluginPath + ?: System.getProperty("vlc.plugin.path") + ?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" } + +if (pluginPath != null && !envVarSetSuccessfully) { + factoryArgs += "--plugin-path=$pluginPath" +} + +val f = MediaPlayerFactory(*factoryArgs.toTypedArray()) +``` + +3. Delete stale plugin cache before factory creation (macOS only): +```kotlin +if ("mac" in System.getProperty("os.name").lowercase()) { + val cacheDir = File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc") + cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() } +} +``` + +4. Audio factory also gets plugin path if env var wasn't set: +```kotlin +val audioArgs = mutableListOf("--no-video", "--no-xlib") +if (pluginPath != null && !envVarSetSuccessfully) { + audioArgs += "--plugin-path=$pluginPath" +} +MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it } +``` + +### File 3: `desktopApp/build.gradle.kts` + +Add JVM property as ultimate fallback: +```kotlin +jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins" +``` + +## Edge Cases from Research + +| Edge Case | Handling | +|-----------|----------| +| `Function.getFunction("c", "setenv")` also fails on macOS | Caught by try/catch, falls back to `--plugin-path` factory arg | +| `--plugin-path` not honored by VLC 3.0.20 | JVM property `-Dvlc.plugin.path` as ultimate fallback | +| Audio factory created later without plugin path | Audio factory also receives `--plugin-path` if env var wasn't set | +| `findVlcDir()` returns root, not plugins dir | Append `/plugins` when building `--plugin-path` value | +| VLC plugin cache stale after VLC update | Cache deleted on startup before factory creation | +| NativeDiscovery swallows MacOsVlcDiscoverer ref | Restructured to hold ref before passing to NativeDiscovery | + +## Acceptance Criteria + +- [ ] Video plays in release DMG on macOS without system VLC installed +- [ ] No `setenv` symbol errors in console output +- [ ] No stale plugin cache warnings on launch +- [ ] Audio playback still works +- [ ] Linux/Windows builds unaffected +- [ ] `./gradlew :desktopApp:run` (debug) still works +- [ ] Fallback chain works: direct setenv → --plugin-path → JVM property + +## Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| `Function.getFunction("c", "setenv")` may also hit versioned symbol | Medium | Triple fallback: direct call → --plugin-path → JVM property | +| `--plugin-path` ignored by some VLC builds | Low | Env var approach is primary, --plugin-path is fallback only | +| Plugin cache deletion too aggressive | Low | Only deletes `plugins*` files in VLC cache dir, not other data | + +## Sources + +- **Origin brainstorm:** [docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md](../brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md) +- **Research:** VLC requires `VLC_PLUGIN_PATH` env var before `libvlc_new()` — `--plugin-path` not reliably forwarded +- **Research:** `--reset-plugins-cache` is CLI-only, not available via libvlc/vlcj +- **Research:** `setPluginPath()` called DURING `nd.discover()`, before factory creation +- **Research:** macOS 13+ versioned symbols affect JNA's `LibC` interface, but `Function.getFunction` may bypass it +- `MacOsVlcDiscoverer.kt:55` — failing `setenv` call +- `VlcjPlayerPool.kt:68-114` — init flow +- `VlcResourceResolver.kt` — returns VLC root dir (not plugins subdir) +- [JNA Issue #1423](https://github.com/java-native-access/jna/issues/1423) — macOS symbol resolution changes +- [Guardsquare/proguard#460](https://github.com/Guardsquare/proguard/issues/460) — related ProGuard bytecode rewriting diff --git a/docs/plans/2026-05-22-feat-desktop-note-action-bar-ux-plan.md b/docs/plans/2026-05-22-feat-desktop-note-action-bar-ux-plan.md new file mode 100644 index 0000000000..be0b8d2843 --- /dev/null +++ b/docs/plans/2026-05-22-feat-desktop-note-action-bar-ux-plan.md @@ -0,0 +1,299 @@ +--- +title: "feat: Desktop note action bar — long-press details + right-click customize" +type: feat +status: active +date: 2026-05-22 +origin: docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md +deepened: 2026-05-22 +--- + +# Desktop Note Action Bar — Long-Press Details + Right-Click Customize + +## Enhancement Summary + +**Deepened on:** 2026-05-22 +**Sections enhanced:** 4 +**Research agents used:** compose-expert, desktop-expert, compose-modifier-and-layout-style, compose-side-effects + +### Key Improvements from Research +1. Use `sealed class ActivePopup` for mutually exclusive popup state +2. Use `DropdownMenu` for simple option lists (emoji picker, repost), `Popup` for rich content (zap/reaction details) +3. Preserve ripple by explicitly passing `indication = ripple(bounded = false, radius = 16.dp)` when replacing `IconButton` +4. Mark `ZapReceipt` as `@Immutable`, consider `ImmutableList` for list params + +### New Considerations Discovered +- `Popup` creates a separate AWT window on JVM — always pass `PopupProperties(focusable = true)` for dismiss-on-click-outside +- Modifier chain order: `combinedClickable` before `onPointerEvent` (ripple wraps full area) +- Skip popup animations on desktop — instant feels right, `fadeIn(tween(100))` at most +- Metadata loading in popup: key `LaunchedEffect` on note ID, not popup visibility + +## Overview + +Add long-press popups and right-click customization to the desktop note action bar. Currently click = action and right-click = custom zap dialog. After this change, every action icon supports three gestures: click (action), long-press (view details), right-click (customize). + +(see brainstorm: docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md) + +## Interaction Model + +| Action | Click | Long Press (~500ms) | Right-Click | +|--------|-------|---------------------|-------------| +| Reply | Open reply composer | Open thread | — | +| Like | React with + | Floating popup: who reacted + emoji | Emoji picker (DropdownMenu) | +| Repost | Repost | — | Quote/Fork (DropdownMenu) | +| Zap | Quick zap 21 sats | Floating popup: zap receipts | Custom zap dialog (existing) | +| Bookmark | Public/private dialog | — | — | + +## Technical Approach + +### Phase 1: Long-Press Detection + Zap Receipts Popup + +**Goal:** Add long-press to zap icon showing floating zap receipts popup. + +#### Step 1.1: Pass `Note` to NoteActionsRow + +Currently `NoteActionsRow` receives `zapReceipts: List = emptyList()` — callers pass empty lists. The actual zap/reaction data lives on the `Note` object. + +**Change:** Add `note: Note? = null` parameter to `NoteActionsRow`. + +**Files:** +- `desktopApp/src/jvmMain/.../ui/NoteActions.kt` — add `note: Note? = null` param +- `desktopApp/src/jvmMain/.../ui/FeedScreen.kt` — pass `note` (already available) +- Others keep default null (BookmarksScreen, ReadsScreen, ArticleReaderScreen) + +#### Step 1.2: Popup State as Sealed Class + +Replace individual boolean states with a single sealed class to ensure mutual exclusivity: + +```kotlin +sealed class ActivePopup { + data object None : ActivePopup() + data object ZapReceipts : ActivePopup() + data object Reactions : ActivePopup() + data object EmojiPicker : ActivePopup() + data object RepostOptions : ActivePopup() +} +var activePopup by remember { mutableStateOf(ActivePopup.None) } +``` + +#### Step 1.3: Replace `IconButton` with `combinedClickable` Box + +Replace the zap `IconButton` with `Box` + `combinedClickable`. Preserve ripple explicitly. + +```kotlin +Box( + modifier = Modifier + .size(32.dp) + .combinedClickable( + onClick = { /* quick zap */ }, + onLongClick = { activePopup = ActivePopup.ZapReceipts }, + indication = ripple(bounded = false, radius = 16.dp), + interactionSource = remember { MutableInteractionSource() }, + ) + .onPointerEvent(PointerEventType.Press) { pointerEvent -> + if (pointerEvent.buttons.isSecondaryPressed) { + showZapDialog = true + } + }, + contentAlignment = Alignment.Center, +) { + Icon(Zap, ...) +} +``` + +**Research insight:** `combinedClickable` before `onPointerEvent` in chain — ripple wraps full area, right-click handler sits inside. + +#### Step 1.4: Zap Receipts Floating Popup + +New composable using `Popup` + `ElevatedCard` (rich scrollable content): + +```kotlin +@Composable +fun ZapReceiptsPopup( + note: Note, + localCache: DesktopLocalCache, + onDismiss: () -> Unit, +) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(0, -popupHeightPx), + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), // required for desktop dismiss + ) { + ElevatedCard( + modifier = Modifier.widthIn(max = 280.dp), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .heightIn(max = 300.dp) + .padding(12.dp), + ) { + // Header: total sats + // Sorted receipts: sender name + amount + message + } + } + } +} +``` + +**Data access from Note:** +```kotlin +val zapEntries = note.zaps.mapNotNull { (request, receipt) -> + val sender = request.author?.toBestDisplayName() + ?: request.event?.pubKey?.take(8) + ?: return@mapNotNull null + val amount = (receipt?.event as? LnZapEvent)?.amount?.toLong() + ?: return@mapNotNull null + Triple(sender, amount, request.event?.content?.ifBlank { null }) +}.sortedByDescending { it.second } +``` + +**Metadata loading:** Use `LaunchedEffect(note.idHex)` to fetch unknown sender metadata. Coroutine auto-cancels when popup leaves composition. + +**Empty state:** If `zapEntries` is empty, show "No zaps yet" text. + +### Phase 2: Reactions Popup + +#### Step 2.1: `combinedClickable` for Like Icon + +Same pattern as zap — click = react, long-press = `activePopup = ActivePopup.Reactions`. + +#### Step 2.2: Reactions Floating Popup + +Same `Popup` + `ElevatedCard` pattern. Content grouped by emoji: + +```kotlin +@Composable +fun ReactionsPopup( + note: Note, + onDismiss: () -> Unit, +) { + // note.reactions: Map> + Popup( + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + ElevatedCard { + Column(Modifier.verticalScroll(rememberScrollState()).heightIn(max = 300.dp)) { + // Header: total count + note.reactions.forEach { (emoji, reactionNotes) -> + // Section: emoji + list of sender names + } + } + } + } +} +``` + +**Research insight:** Use `derivedStateOf` for the grouped reaction view to avoid recomposition on every upstream emission. + +### Phase 3: Right-Click Emoji Picker + +#### Step 3.1: Add right-click to Like Icon + +Add `onPointerEvent` secondary press → `activePopup = ActivePopup.EmojiPicker`. + +#### Step 3.2: Emoji Picker as DropdownMenu + +Use `DropdownMenu` (not raw `Popup`) — matches existing `AccountSwitcherDropdown` pattern. Simple option list. + +```kotlin +DropdownMenu( + expanded = activePopup is ActivePopup.EmojiPicker, + onDismissRequest = { activePopup = ActivePopup.None }, +) { + listOf("+", "\u2764\ufe0f", "\ud83e\udd19", "\ud83d\udd25", "\ud83d\udc40", "\ud83d\ude02").forEach { emoji -> + DropdownMenuItem( + text = { Text(emoji, fontSize = 20.sp) }, + onClick = { + activePopup = ActivePopup.None + onReact(emoji) + }, + ) + } +} +``` + +### Phase 4: Right-Click Repost Options + +#### Step 4.1: Repost DropdownMenu + +Right-click on repost icon → `DropdownMenu` with Quote / Fork options: + +```kotlin +DropdownMenu( + expanded = activePopup is ActivePopup.RepostOptions, + onDismissRequest = { activePopup = ActivePopup.None }, +) { + DropdownMenuItem(text = { Text("Repost") }, onClick = { /* repost */ }) + DropdownMenuItem(text = { Text("Quote") }, onClick = { /* quote */ }) +} +``` + +--- + +## File Changes Summary + +| File | Changes | +|------|---------| +| `NoteActions.kt` | Add `note: Note?` param, `ActivePopup` sealed class, replace `IconButton` with `combinedClickable` for zap/like/repost, add `ZapReceiptsPopup`, `ReactionsPopup`, emoji picker, repost options. Mark `ZapReceipt` as `@Immutable`. | +| `FeedScreen.kt` | Pass `note` to `NoteActionsRow` | + +## Acceptance Criteria + +- [ ] Long-press (~500ms) on zap icon shows floating popup with zap receipts (sender, amount, message) +- [ ] Long-press on like icon shows floating popup with reactions grouped by emoji +- [ ] Long-press on reply icon opens thread (same as click) +- [ ] Right-click on zap icon opens custom zap dialog (existing behavior preserved) +- [ ] Right-click on like icon opens emoji picker (DropdownMenu) +- [ ] Right-click on repost icon shows Quote/Fork options (DropdownMenu) +- [ ] Single click still works for all actions (zap, react, repost, reply, bookmark) +- [ ] Popups are mutually exclusive (opening one closes others) +- [ ] Popups dismiss on click outside (`PopupProperties(focusable = true)`) +- [ ] Popups are scrollable when content exceeds 300dp +- [ ] No crash when long-pressing on notes with 0 zaps/reactions (empty state) +- [ ] Ripple preserved on all action icons +- [ ] Compiles, spotless clean, tests pass + +## Implementation Order + +1. **Phase 1** — Zap long-press popup (highest value, proves the pattern) +2. **Phase 2** — Reactions popup (same pattern, different data) +3. **Phase 3** — Emoji picker (small addition) +4. **Phase 4** — Repost options (nice-to-have) + +## Technical Notes from Research + +### Modifier Chain Order +``` +Modifier + .size(32.dp) // identity: fixed touch target + .combinedClickable(...) // identity: click + long-press + .onPointerEvent(Press) { ... } // identity: right-click +``` +`combinedClickable` first (provides ripple/indication), `onPointerEvent` after. + +### Popup vs DropdownMenu Decision +| Content Type | API | +|-------------|-----| +| Rich scrollable content (zap receipts, reactions) | `Popup` + `ElevatedCard` | +| Simple option list (emoji picker, repost options) | `DropdownMenu` + `DropdownMenuItem` | + +### Desktop-Specific +- `PopupProperties(focusable = true)` is **required** on JVM desktop for click-outside dismiss +- `Popup` creates separate AWT window — can extend beyond parent window bounds (good) +- Skip animations or use `fadeIn(tween(100))` at most — desktop users expect crisp/instant + +### Side Effects in Popups +- Metadata loading: `LaunchedEffect(note.idHex)` — auto-cancels when popup leaves composition +- No `DisposableEffect` needed unless opening relay subscriptions +- Use `derivedStateOf` for grouped reaction view + +## Sources + +- **Origin brainstorm:** [docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md](docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md) — interaction model, popup style, per-action behavior +- Android `ReactionsRow.kt` — `combinedClickable`, `Popup`, animation patterns +- Android `Note.kt` — `reactions: Map>`, `zaps: Map` +- Desktop `NoteActions.kt` — `onPointerEvent` right-click, `ZapReceiptsDialog` +- Desktop `AccountSwitcherDropdown.kt` — `DropdownMenu` scroll/height pattern +- Desktop `ChatBubbleLayout.kt:139` — `combinedClickable` proven on JVM desktop diff --git a/docs/plans/2026-05-22-fix-desktop-note-action-counters-and-quote-plan.md b/docs/plans/2026-05-22-fix-desktop-note-action-counters-and-quote-plan.md new file mode 100644 index 0000000000..facca72812 --- /dev/null +++ b/docs/plans/2026-05-22-fix-desktop-note-action-counters-and-quote-plan.md @@ -0,0 +1,136 @@ +--- +title: "fix: Desktop note action counters, quote boost, and boost detail popup" +type: fix +status: active +date: 2026-05-22 +--- + +# Fix Desktop Note Action Counters, Quote Boost, and Boost Detail Popup + +## Problems + +### 1. Counters show zero for reactions, zaps, replies, reposts + +Counts are passed as **static Int/Long parameters** to `NoteActionsRow`. They're read once at render time from `note.countReactions()`, `note.zaps.size`, etc. When new events arrive via relay subscriptions, the FlowSet invalidates — triggering recomposition of FeedNoteCard — but the counts are re-read from the same snapshot. The actual issue is **timing**: interaction subscriptions (`requestInteractions()`) fire for visible notes, but events may arrive after the initial render. + +Additionally, **reply subscriptions are missing** — `DesktopRelaySubscriptionsCoordinator.requestInteractions()` subscribes for kinds 7, 9735, 6 but NOT kind 1 (replies). + +### 2. Quote boost does nothing + +The "Quote" `DropdownMenuItem` onClick just copies a note link to clipboard. It doesn't open a compose dialog. `ComposeNoteDialog` has no `quote` parameter. + +### 3. Boost long-press has no popup + +Repost icon has no `onLongClick` handler — long-press does nothing. Should show who boosted. + +## Technical Approach + +### Phase 1: Fix counters — make them reactive + +**Root cause:** FeedNoteCard reads counts once as vals, passes them as params. Even though FlowSet observations are collected, the count vals are re-read from Note's mutable properties which DO update — but only if the recomposition actually re-reads them. + +**Fix:** The FlowSet observation pattern is actually correct — collecting `flowSet.reactions.stateFlow` triggers recomposition which re-reads `note.countReactions()`. The problem may be that interaction events haven't arrived yet. + +**Step 1.1: Add kind 1 (replies) to interaction subscriptions** + +File: `desktopApp/src/jvmMain/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt` + +In `requestInteractions()`, add kind 1 to the filter list: +```kotlin +Filter(kinds = listOf(1), tags = mapOf("e" to noteIds)), // Replies +``` + +**Step 1.2: Verify flow collection triggers recomposition** + +In `FeedScreen.kt`, the pattern is: +```kotlin +val reactionsState by flowSet.reactions.stateFlow.collectAsState() +// ... +val reactionCount = note.countReactions() +``` + +This should work — `collectAsState()` triggers recomposition, which re-reads `countReactions()`. If it's not working, add `reactionsState` as a key to `remember`: +```kotlin +val reactionCount = remember(reactionsState) { note.countReactions() } +``` + +### Phase 2: Fix quote boost + +**Step 2.1: Add `quoteOf` parameter to ComposeNoteDialog** + +File: `desktopApp/src/jvmMain/.../ui/ComposeNoteDialog.kt` + +Add `quoteOf: Event? = null` parameter. When set, embed `nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}` in the initial text and add "q" tag. + +**Step 2.2: Wire Quote menu item to compose dialog** + +File: `desktopApp/src/jvmMain/.../ui/NoteActions.kt` + +Replace clipboard copy with opening ComposeNoteDialog: +```kotlin +var quoteEvent by remember { mutableStateOf(null) } + +// In Quote DropdownMenuItem onClick: +quoteEvent = event +activePopup = ActivePopup.None + +// Render dialog: +if (quoteEvent != null) { + ComposeNoteDialog(quoteOf = quoteEvent, onDismiss = { quoteEvent = null }, ...) +} +``` + +**Step 2.3: Build quote event with "q" tag** + +When composing, use TextNoteEvent builder with "q" tag: +```kotlin +TextNoteEvent.build( + message = "$userMessage\nnostr:${NEvent.create(quotedEvent.id, ...)}", + tags = arrayOf(arrayOf("q", quotedEvent.id, relayHint ?: "", quotedEvent.pubKey)), + signer = signer, +) +``` + +### Phase 3: Add boost detail popup + +**Step 3.1: Add long-press to repost icon** + +Same `combinedClickable` pattern. Long-press sets `activePopup = ActivePopup.Boosts`. + +**Step 3.2: Create BoostsPopup** + +New composable showing who boosted: +```kotlin +@Composable +fun BoostsPopup(note: Note, onDismiss: () -> Unit) { + // note.boosts: List + // Each boost Note has .author → display name + Popup(properties = PopupProperties(focusable = true)) { + ElevatedCard { ... } + } +} +``` + +Add `Boosts` to the `ActivePopup` sealed class. + +## File Changes + +| File | Changes | +|------|---------| +| `DesktopRelaySubscriptionsCoordinator.kt` | Add kind 1 to `requestInteractions()` filter | +| `FeedScreen.kt` | Ensure count reads are keyed on flow state | +| `NoteActions.kt` | Wire quote to compose dialog, add boost long-press popup, add `ActivePopup.Boosts` | +| `ComposeNoteDialog.kt` | Add `quoteOf: Event?` param, embed quote in text + "q" tag | + +## Acceptance Criteria + +- [ ] Reaction count updates when new reactions arrive via relay +- [ ] Zap count/amount updates when new zaps arrive +- [ ] Reply count shows and updates +- [ ] Repost count shows and updates +- [ ] Kind 1 replies subscribed in interaction filters +- [ ] Quote menu item opens ComposeNoteDialog with quoted note +- [ ] Quote posts include "q" tag and embedded nostr: URI +- [ ] Long-press repost icon shows popup with who boosted +- [ ] Empty state for boosts popup: "No reposts yet" +- [ ] Compiles, spotless, tests pass diff --git a/docs/plans/2026-05-28-feat-desktop-search-spotlight-plan.md b/docs/plans/2026-05-28-feat-desktop-search-spotlight-plan.md new file mode 100644 index 0000000000..9e4d8b4eb3 --- /dev/null +++ b/docs/plans/2026-05-28-feat-desktop-search-spotlight-plan.md @@ -0,0 +1,246 @@ +--- +title: "feat: Desktop Search Spotlight + Unified Feed Header Bar" +type: feat +status: active +date: 2026-05-28 +origin: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md +--- + +# feat: Desktop Search Spotlight + Unified Feed Header Bar + +## Overview + +Add a global search spotlight overlay (Cmd+F) and a unified feed header bar that combines feed tabs with a search pill. Also fix the bug where "+ Add more" feeds only works from the Home feed by moving it to the sidebar. + +Three components: +1. **SearchSpotlight** — global overlay with scrim, auto-focus, recent/saved searches, live results +2. **FeedHeaderBar** — unified feed tabs (My Feed | Global | pinned custom) + search pill, used on every feed column +3. **Sidebar "+ Add Feed"** — move from broken feed header to always-accessible sidebar + +(see brainstorm: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md) + +## Problem Statement + +1. **No quick search** — searching requires opening a full Search column. No spotlight/command-palette UX. +2. **Feed tabs disconnected from search** — the screenshot reference shows tabs + search in a unified bar, but current feed columns have separate headers with no search pill. +3. **"+ Add more" bug** — the button to add custom feeds only works on the Home feed; from Global or custom feeds it's broken/invisible. + +## Proposed Solution + +### Search Spotlight (Cmd+F) + +Global overlay that dims content (50% scrim), shows a centered search card (~600dp wide, 20% from top): +- **Empty state:** Recent searches + saved searches from `SearchHistoryStore` +- **Typing:** Live people + note results (reuse `AdvancedSearchBarState` with 300ms debounce) +- **Result selection:** Opens new column (deck) or navigates (single-pane) +- **"Open full search"** link at bottom → opens Search column with current query +- **Keyboard:** Escape closes, arrow keys navigate results, Enter selects + +### Unified Feed Header Bar + +Replaces the current `ColumnHeader` on feed-type columns: +``` +┌──────────────────────────────────────────────┐ +│ [My Feed] [Global] [Custom1] 🔍 Search.. ⌘F │ +└──────────────────────────────────────────────┘ +``` +- Left: feed tabs (Following + Global always, up to 3 pinned custom feeds) +- Right: compact SearchPill that opens the spotlight +- Active tab: `primary` color indicator +- 48dp height, `surfaceContainer` background + +### Sidebar "+ Add Feed" + +Move from feed column header to MainSidebar's FEEDS section — always accessible. + +## Technical Approach + +### Implementation Phases + +#### Phase 1: SearchSpotlight Composable + +New file: `desktopApp/.../ui/search/SearchSpotlight.kt` + +**Architecture:** +- `Dialog` with custom `Surface` (not `AlertDialog` — need full layout control) +- Scrim: `Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f)).clickable { onDismiss() })` +- Search card: `Surface(shape = shapes.large, color = surface)` centered with `600.dp` max width +- Input: `BasicTextField` with pill decoration (matching SearchScreen pattern), auto-focused via `FocusRequester` +- State: Create `AdvancedSearchBarState(scope)` scoped to spotlight lifecycle. `DisposableEffect` stops subscriptions on close. +- Results: `LazyColumn` with sections (People max 5, Notes max 5), each item clickable +- History: Read from `SearchHistoryStore` on open + +**Key composables:** +```kotlin +@Composable +fun SearchSpotlight( + localCache: DesktopLocalCache, + relayManager: DesktopRelayConnectionManager, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + account: AccountState.LoggedIn, + searchHistoryStore: SearchHistoryStore, + onSelectProfile: (String) -> Unit, // pubkey hex + onSelectNote: (String) -> Unit, // note id hex + onSelectHashtag: (String) -> Unit, // tag + onOpenFullSearch: (String) -> Unit, // query text + onDismiss: () -> Unit, +) +``` + +**Success criteria:** +- [ ] Spotlight opens centered with scrim dimming background +- [ ] Input auto-focused on open +- [ ] Recent + saved searches shown before typing +- [ ] Live people/note results appear while typing (300ms debounce) +- [ ] Selecting result calls appropriate callback and closes +- [ ] Escape closes spotlight +- [ ] Arrow key navigation through results +- [ ] "Open full search" opens Search column with query + +#### Phase 2: SearchPill Composable + +New file: `desktopApp/.../ui/search/SearchPill.kt` + +Small reusable pill: +```kotlin +@Composable +fun SearchPill( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) +``` +- Pill shape (999dp corners), `surfaceContainerHigh` background +- Search icon + "Search..." + "⌘F" hint +- Height: 36dp +- Hover highlight via `hoverHighlight()` modifier + +**Success criteria:** +- [ ] Renders as compact pill with search icon and shortcut hint +- [ ] Click opens spotlight +- [ ] Hover highlights + +#### Phase 3: FeedHeaderBar Composable + +New file: `desktopApp/.../ui/search/FeedHeaderBar.kt` + +Unified header for feed columns: +```kotlin +@Composable +fun FeedHeaderBar( + feedTabs: ImmutableList, + activeFeedId: String, + onTabClick: (String) -> Unit, + onSearchClick: () -> Unit, + modifier: Modifier = Modifier, +) + +data class FeedTab( + val id: String, + val label: String, + val isBuiltIn: Boolean = false, // Following/Global are built-in +) +``` + +**Layout:** +- `Row` with `surfaceContainer` background, 48dp height +- Left: scrollable `Row` of tab chips/buttons +- Right: `SearchPill` with fixed width +- Active tab: filled with `primary`, others `onSurfaceVariant` +- Divider at bottom: 1dp `outlineVariant` + +**Feed tabs source:** +- "My Feed" (Following) — always first, `id = "following"` +- "Global" — always second, `id = "global"` +- Pinned custom feeds from `LocalFeedRepository.current` (max 3) + +**Integration:** Replace `ColumnHeader` for `DeckColumnType.HomeFeed`, `DeckColumnType.GlobalFeed`, and `DeckColumnType.CustomFeed` in `DeckColumnContainer.kt`. + +**Success criteria:** +- [ ] Feed tabs render with Following + Global + up to 3 pinned +- [ ] Active tab highlighted with primary color +- [ ] Tab click switches feed +- [ ] Search pill visible on right side +- [ ] Shown on all feed-type columns + +#### Phase 4: Sidebar "+ Add Feed" + Keyboard Shortcut + +**MainSidebar (DeckSidebar.kt):** +- Add "+ Add Feed" item at bottom of FEEDS section +- Click opens feed builder/drawer (existing `onOpenFeedsDrawer` callback) +- Styled as subtle text link with `+` icon + +**Main.kt:** +- Add `Cmd+F` keyboard shortcut in MenuBar +- Add `showSearchSpotlight` state +- Render `SearchSpotlight` when `showSearchSpotlight` is true +- Wire result callbacks to `deckState.addColumn()` / `singlePaneState.navigate()` + +**FeedScreen.kt:** +- Remove inline "+ Add more" button (now in sidebar) + +**Success criteria:** +- [ ] Cmd+F opens spotlight from anywhere in the app +- [ ] "+ Add Feed" visible in sidebar FEEDS section +- [ ] "+ Add Feed" works regardless of current feed view +- [ ] Old "+ Add more" button removed from feed headers + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Cmd+F opens search spotlight overlay with scrim +- [ ] Spotlight shows recent + saved searches on open +- [ ] Live search results (people + notes) with 300ms debounce +- [ ] Selecting a profile opens it (new column in deck, navigate in single-pane) +- [ ] Selecting a note opens thread +- [ ] "Open full search" opens Search column with query +- [ ] Escape closes spotlight +- [ ] FeedHeaderBar shows feed tabs + search pill on all feed columns +- [ ] Tab switching works (Following ↔ Global ↔ Custom feeds) +- [ ] "+ Add Feed" in sidebar works from any screen +- [ ] Keyboard arrow navigation through spotlight results + +### Non-Functional Requirements + +- [ ] Spotlight opens in <100ms (no relay calls until user types) +- [ ] Scrim renders at 60fps +- [ ] Search results appear within 300ms of typing pause +- [ ] Compiles on all platforms (macOS, Windows, Linux) +- [ ] Spotless clean + +## Dependencies + +- `AdvancedSearchBarState` (commons/) — reuse, no changes needed +- `SearchHistoryStore` (desktop) — reuse, no changes needed +- `SearchFilterFactory` (desktop) — reuse for relay subscriptions +- `FeedDefinitionRepository` (commons/) — read pinned feeds for tabs +- `MainSidebar` — add "+ Add Feed" item +- `hoverHighlight()` modifier — already created in visual personality PR + +## Files Affected + +| File | Action | +|------|--------| +| New: `desktopApp/.../ui/search/SearchSpotlight.kt` | Spotlight overlay | +| New: `desktopApp/.../ui/search/SearchPill.kt` | Compact pill component | +| New: `desktopApp/.../ui/search/FeedHeaderBar.kt` | Unified feed tabs + search | +| `desktopApp/.../Main.kt` | Cmd+F shortcut, spotlight state, render, result callbacks | +| `desktopApp/.../ui/deck/DeckColumnContainer.kt` | Use FeedHeaderBar for feed columns | +| `desktopApp/.../ui/deck/DeckSidebar.kt` | Add "+ Add Feed" to FEEDS section | +| `desktopApp/.../ui/FeedScreen.kt` | Remove "+ Add more" if present, adapt header | +| `desktopApp/.../ui/deck/ColumnHeader.kt` | May need trailing slot for non-feed columns | + +## Sources & References + +### Origin + +- **Brainstorm:** [docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md](docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md) — Key decisions: Cmd+F spotlight, unified FeedHeaderBar, sidebar "+ Add Feed" + +### Internal References + +- `SearchScreen.kt` — existing full search column (keep as-is) +- `AdvancedSearchBarState.kt` — reusable search state management +- `SearchHistoryStore.kt` — recent/saved search persistence +- `SearchFilterFactory.kt` — NIP-50 relay filter construction +- `FeedDefinitionRepository.kt` — pinned custom feeds source +- `MainSidebar (DeckSidebar.kt)` — sidebar where "+ Add Feed" moves to diff --git a/docs/plans/2026-05-28-feat-desktop-visual-personality-plan.md b/docs/plans/2026-05-28-feat-desktop-visual-personality-plan.md new file mode 100644 index 0000000000..031de753ae --- /dev/null +++ b/docs/plans/2026-05-28-feat-desktop-visual-personality-plan.md @@ -0,0 +1,751 @@ +--- +title: "feat: Desktop Visual Personality Overhaul" +type: feat +status: active +date: 2026-05-28 +origin: docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md +--- + +# feat: Desktop Visual Personality Overhaul + +## Enhancement Summary + +**Deepened on:** 2026-05-28 +**Research agents used:** compose-expert, desktop-expert, compose-stability-diagnostics, compose-modifier-and-layout-style, best-practices-researcher, color-audit-explorer + +### Critical Fixes (from research) +1. **`ColorScheme.isLight` doesn't exist in M3** — use `LocalIsDarkTheme` CompositionLocal instead +2. **Hover modifier won't compile** — `@Composable` lambda can't invoke in `drawBehind`; `shape.topStart` invalid on generic Shape. Rewrite using `onPointerEvent` (codebase convention) +3. **Sidebar animation thrashes column widths** — `fitColumnsToWidth()` fires every frame during 240→56dp transition. Add 300ms debounce +4. **NoteCard/NoteCardSkeleton missing `modifier` parameter** — required for reusable composables +5. **AccountSwitcher dropdown offset hardcoded to 48dp** — needs dynamic offset based on sidebar width + +### Key Improvements +- Use `onPointerEvent` for hover (project convention, not `composed{}`) +- Add `clipToBounds()` on sidebar during animation +- Add keyboard shortcuts: `Cmd/Ctrl+B` (sidebar toggle), `Cmd/Ctrl+K` (search focus) +- Gate SegmentedButton on column width > 400dp +- `remember` BorderStroke to avoid instance churn defeating skip optimization +- ShimmerPlaceholder in `commons/commonMain` (pure M3, no platform APIs) +- Use `LocalScrollbarStyle` for scrollbar customization (built-in API) +- Add `LocalIsDarkTheme` CompositionLocal alongside `LocalSpacing` +- SegmentedButton confirmed available and stable (already used in AppDrawer.kt) +- 32 inline `Color()` literals to migrate across 10 files + +### Color Audit Results +- **32 inline Color() constructors** across 10 desktop files (status indicators: green/red/amber) +- **48 `.copy(alpha=)` patterns** across 18 files (standardize with alpha constants) +- **Key files:** DevSettingsSection (10), LoginProgressSteps (5), NamecoinSettingsSection (4), TorStatusIndicator (3) + +## Overview + +Transform Amethyst Desktop from an OS-native-adaptive look into a distinctly branded experience. Replace per-OS color schemes with a unified Amethyst identity (cyan/blue accent), redesign the sidebar to 240dp with labels, restyle cards to flat+border, add hover effects, skeleton loading, and polish all UI components. + +Desktop-only scope — Android unchanged. (see brainstorm: Decision #5) + +## Problem Statement + +The current desktop theme adapts to each OS (macOS, GNOME, KDE, Windows) with per-OS colors, shapes, and fonts. While technically impressive, this makes the app visually neutral — it doesn't feel like "Amethyst." The 56dp icon-only sidebar wastes desktop screen space and hurts discoverability. Cards use shadow elevation that renders inconsistently on desktop JVM/Skia. + +## Proposed Solution + +A 6-phase implementation that progresses from theme foundation → spacing → sidebar → cards → column headers → polish. Each phase builds on the previous, and the app remains functional throughout. + +## Technical Approach + +### Architecture + +The overhaul touches 3 layers: + +1. **Theme layer** (`desktopApp/.../platform/`) — ColorScheme, Typography, Shapes +2. **Layout layer** (`desktopApp/.../ui/deck/`) — Sidebar, ColumnHeader, DeckLayout +3. **Component layer** (`desktopApp/.../ui/note/`, shared composables) — NoteCard, action bar, dialogs + +New additions: +- `AmethystSpacing` CompositionLocal for design tokens +- `Modifier.hoverHighlight()` shared hover utility +- `ShimmerPlaceholder` composable for loading states + +### Implementation Phases + +--- + +#### Phase 1: Theme Foundation + +**Goal:** Replace all per-OS color schemes with unified Amethyst brand. Unified shapes and typography weights. + +**Files:** + +| File | Action | +|------|--------| +| `desktopApp/.../platform/PlatformColorScheme.kt` | Replace 10 per-OS functions with `amethystLight()` + `amethystDark()` | +| `desktopApp/.../platform/PlatformShapes.kt` | Replace per-OS shapes with unified tokens | +| `desktopApp/.../platform/PlatformTypography.kt` | Standardize weight scale across all OS | +| `commons/.../ui/theme/Colors.kt` | Add cyan/blue brand constants | + +**Color Scheme (see brainstorm: Layer 1):** + +```kotlin +// commons/ui/theme/Colors.kt — new constants +val AmethystBlue = Color(0xFF0096FF) // light primary +val AmethystBlueDark = Color(0xFF4DB8FF) // dark primary +val AmethystPurple = Color(0xFF9A82DB) // tertiary (heritage) + +// PlatformColorScheme.kt — amethystLight() +fun amethystLight() = lightColorScheme( + primary = AmethystBlue, // #0096FF + onPrimary = Color.White, + primaryContainer = AmethystBlue.copy(alpha = 0.12f).compositeOver(Color.White), + onPrimaryContainer = AmethystBlue, + secondary = Color(0xFF5E8FAD), // desaturated blue + onSecondary = Color.White, + tertiary = AmethystPurple, // heritage purple + onTertiary = Color.White, + background = Color(0xFFF2F2F7), // light gray + onBackground = Color(0xFF1C1C1E), + surface = Color.White, + onSurface = Color(0xFF1C1C1E), + surfaceVariant = Color(0xFFF0F0F5), + onSurfaceVariant = Color(0xFF6E6E73), + surfaceContainer = Color(0xFFF7F7FA), + surfaceContainerHigh = Color(0xFFEEEEF2), + surfaceContainerHighest = Color(0xFFE5E5EA), + surfaceContainerLow = Color(0xFFFAFAFC), + outline = Color(0xFFE0E0E0), + outlineVariant = Color(0xFFEBEBEB), + error = Color(0xFFBA1A1A), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), +) + +// amethystDark() — same structure with dark values +fun amethystDark() = darkColorScheme( + primary = AmethystBlueDark, // #4DB8FF + onPrimary = Color.White, + primaryContainer = AmethystBlueDark.copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)), + onPrimaryContainer = AmethystBlueDark, + secondary = Color(0xFF7EAEC8), + onSecondary = Color.White, + tertiary = Color(0xFFB6A0E0), // lighter purple + onTertiary = Color.White, + background = Color(0xFF121212), + onBackground = Color(0xFFE5E5EA), + surface = Color(0xFF1E1E1E), + onSurface = Color(0xFFE5E5EA), + surfaceVariant = Color(0xFF2A2A2A), + onSurfaceVariant = Color(0xFF9E9EA3), + surfaceContainer = Color(0xFF252525), + surfaceContainerHigh = Color(0xFF2E2E2E), + surfaceContainerHighest = Color(0xFF383838), + surfaceContainerLow = Color(0xFF1A1A1A), + outline = Color(0xFF3A3A3A), + outlineVariant = Color(0xFF2E2E2E), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), +) +``` + +**`resolve()` simplification:** +```kotlin +fun resolve(isDark: Boolean): ColorScheme = + if (isDark) amethystDark() else amethystLight() +``` + +Remove: `macOsLight()`, `macOsDark()`, `gnomeLight()`, `gnomeDark()`, `kdeLight()`, `kdeDark()`, `windowsLight()`, `windowsDark()`, `genericLight()`, `genericDark()`, `darkenForLight()`, accent parameter from `resolve()`. Keep `onAccent()` only if still needed elsewhere. + +**Shapes (unified):** +```kotlin +// PlatformShapes.kt +val current = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), +) +``` + +Remove all per-OS shape functions. + +**Typography weights:** +- Display: `FontWeight.Light` (300) +- Headline: `FontWeight.SemiBold` (600) — keep +- Title: `FontWeight.SemiBold` / `FontWeight.Medium` — keep +- Body: `FontWeight.Normal` — keep +- Label: `FontWeight.Medium` — keep +- Standardize letter spacing to `-0.3sp` for display/headline across all OS + +Keep per-OS font family detection — fonts ARE platform-specific. + +**PlatformTheme.kt changes:** +- Remove accent resolution (`PlatformAccent.systemAccent()`) +- Simplify to `PlatformColorScheme.resolve(isDark)` (no accent param) +- Keep `titleBarInsetTop` and `applyNativeWindowChrome()` + +**Migration:** Grep `desktopApp/` for inline `Color(0xFF...)` constructors and replace with `MaterialTheme.colorScheme.*` references where possible. + +**Success criteria:** +- [ ] App compiles with unified color scheme on all OS +- [ ] Dark/light mode toggle works correctly +- [ ] No per-OS color/shape variance remains +- [ ] Typography weights follow standardized scale + +--- + +#### Phase 2: Spacing System + +**Goal:** Create a design token system for consistent spacing across all desktop UI. + +**Files:** + +| File | Action | +|------|--------| +| New: `desktopApp/.../ui/theme/AmethystSpacing.kt` | Create spacing tokens + CompositionLocal | +| `desktopApp/.../platform/PlatformTheme.kt` | Provide `LocalSpacing` | + +**Implementation:** + +```kotlin +// AmethystSpacing.kt +@Immutable +data class AmethystSpacing( + val xxs: Dp = 2.dp, + val xs: Dp = 4.dp, + val sm: Dp = 8.dp, + val md: Dp = 12.dp, + val lg: Dp = 16.dp, + val xl: Dp = 24.dp, + val xxl: Dp = 32.dp, + val cardPadding: Dp = 16.dp, + val cardGap: Dp = 8.dp, + val sidebarExpandedWidth: Dp = 240.dp, + val sidebarCollapsedWidth: Dp = 56.dp, + val columnHeaderHeight: Dp = 48.dp, +) + +val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() } + +val MaterialTheme.spacing: AmethystSpacing + @Composable @ReadOnlyComposable + get() = LocalSpacing.current +``` + +**In PlatformTheme.kt:** +```kotlin +val LocalIsDarkTheme = staticCompositionLocalOf { false } + +// In PlatformMaterialTheme: +CompositionLocalProvider( + LocalSpacing provides AmethystSpacing(), + LocalIsDarkTheme provides isDark, +) { + MaterialTheme(colorScheme, typography, shapes, content) +} +``` + +> **Research insight:** `ColorScheme.isLight` does not exist in Material3 Compose (M2 only). Provide `LocalIsDarkTheme` alongside `LocalSpacing` so dialogs/components can detect dark mode. `staticCompositionLocalOf` is correct — matches `LocalDesktopCache`, `LocalRelayManager` pattern in Main.kt. + +**Success criteria:** +- [ ] `MaterialTheme.spacing.*` accessible throughout desktop app +- [ ] `LocalIsDarkTheme.current` available for dark mode detection +- [ ] No functional changes — this is infrastructure for subsequent phases + +--- + +#### Phase 3: Sidebar Redesign + +**Goal:** Transform the 56dp icon-only sidebar into a 240dp labeled navigation with avatar, custom feeds, and animated collapse. + +**Files:** + +| File | Action | +|------|--------| +| `desktopApp/.../ui/deck/DeckSidebar.kt` | Major rewrite — wide layout, labels, sections | +| `desktopApp/.../ui/account/AccountSwitcherDropdown.kt` | Move avatar to sidebar top, restyle | +| `desktopApp/.../ui/deck/DeckLayout.kt` | Adjust sidebar width to use animated state | + +**Current sidebar structure (DeckSidebar.kt):** +- 56.dp wide Column +- AccountSwitcherDropdown (person icon) → Add Column → Import → Spacer → Bunker/Tor → Settings +- No labels, no active state indicator, no feeds section + +**New sidebar structure:** + +``` +┌─────────────────────────┐ +│ [Avatar 40dp] Username │ ← tappable, opens account switcher +│ @npub... │ +├─────────────────────────┤ +│ 🏠 Home │ ← main nav items +│ 🔍 Search │ +│ ✉️ Messages │ +│ 💰 Wallet │ +│ 🔖 Bookmarks │ +│ ⚙️ Settings │ +├─────────────────────────┤ +│ FEEDS │ ← section header +│ 📷 Photography │ ← from FeedDefinitionRepository +│ 🏡 Homestead │ +│ 🏛️ Architecture │ +├─────────────────────────┤ +│ │ ← spacer +│ [Bunker] [Tor] │ ← status indicators +│ ◀ Collapse │ ← collapse toggle +└─────────────────────────┘ +``` + +**Key implementation details:** + +1. **Animated width:** `animateDpAsState(if (expanded) 240.dp else 56.dp, tween(300, easing = FastOutSlowInEasing))` + `Modifier.clipToBounds()` on sidebar container +2. **Column width debounce:** DeckLayout's `fitColumnsToWidth()` fires via `LaunchedEffect(availableWidthDp)`. During sidebar animation, this thrashes every frame. Add `snapshotFlow { availableWidthDp }.debounce(300)` or gate on animation completion. +3. **Collapse persistence:** `java.util.prefs.Preferences` key `sidebar_collapsed` +3. **Nav items:** Map `DeckColumnType` to sidebar entries with icon + label +4. **Active state:** `primaryContainer` background pill with `primary` tinted icon + `SemiBold` text +5. **Hover state:** `onSurface.copy(alpha = 0.08f)` background on hover (see Phase 6 for shared utility) +6. **Feeds section:** Read from `LocalFeedRepository.current` → `groupedFeeds.pinned + myFeeds` +7. **Feed icons:** Each `FeedDefinition` gets an icon field (Material Symbol codepoint). Default to a generic feed icon. +8. **Avatar:** 40dp circular, loaded from `LocalCache` user metadata. Fallback to `Person` icon. +9. **Account switcher:** Tap avatar opens existing `AccountSwitcherDropdown` (restyled with rounded corners). **Fix dropdown offset:** current hardcoded `DpOffset(x = 48.dp)` must become dynamic based on sidebar width. +10. **Collapsed mode:** Only icons shown, no labels, tooltip on hover. Width animates to 56dp. **Accessibility:** Add `contentDescription` to icons when labels are hidden. +11. **Keyboard shortcut:** `Cmd/Ctrl+B` to toggle sidebar collapse (add to MenuBar in Main.kt). + +**Label fade animation:** +```kotlin +AnimatedVisibility( + visible = expanded, + enter = fadeIn(tween(200, delayMillis = 100)), + exit = fadeOut(tween(100)), +) { + Text(label, style = MaterialTheme.typography.labelLarge) +} +``` + +**Success criteria:** +- [ ] Sidebar shows 240dp with avatar + nav items + feeds +- [ ] Active item has cyan pill indicator +- [ ] Collapse toggle animates smoothly +- [ ] Collapse state persists across restarts +- [ ] Custom feeds appear in FEEDS section +- [ ] Account switcher works from avatar tap + +--- + +#### Phase 4: Card & Action Bar Refinement + +**Goal:** Restyle NoteCard to flat+border, update action bar to match screenshot, add image corner treatment. + +**Files:** + +| File | Action | +|------|--------| +| `desktopApp/.../ui/note/NoteCard.kt` | Flat + border, 16dp padding, action bar, image corners | + +**Card changes:** + +> **Research fix:** `BorderStroke` creates new instance each recomposition, defeating skip. Remember it. NoteCard must accept `modifier` parameter. + +```kotlin +@Composable +fun NoteCard( + // ... other params, + modifier: Modifier = Modifier, +) { + val border = remember(MaterialTheme.colorScheme.outlineVariant) { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + } + OutlinedCard( + modifier = modifier, + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = border, + shape = MaterialTheme.shapes.medium, // 12dp + ) { + Column(modifier = Modifier.padding(MaterialTheme.spacing.cardPadding)) { ... } + } +} +``` + +> **Scope note:** 10+ files use `CardDefaults.cardColors` across desktop (NotificationsScreen, ChessScreen, ProfileInfoCard, WalletColumnScreen, etc.). All need consistent conversion to OutlinedCard. + +**Action bar redesign:** +- Icon size: 20dp (down from current) +- Layout: `Row` with `Arrangement.spacedBy(16.dp)` +- Each action: Icon + count `Text(labelSmall)` in a `Row(spacing = 4.dp)` +- Active states: filled icon + `primary` color (liked heart, zapped lightning, reposted) +- Icons: comment, heart, zap (lightning bolt), repost, share + +**Image treatment:** +- All inline images: `clip(RoundedCornerShape(8.dp))` +- Images get `padding(top = 8.dp)` for spacing from text above +- Already partially implemented (8.dp corners exist) — ensure consistency + +**Card gaps:** +- Remove any `HorizontalDivider` between cards in feed lists +- Use `Arrangement.spacedBy(MaterialTheme.spacing.cardGap)` (8dp) in `LazyColumn` + +**Success criteria:** +- [ ] Cards show flat white surface with subtle border +- [ ] 16dp internal padding +- [ ] Action bar uses smaller icons with count badges +- [ ] Images have rounded corners +- [ ] No divider lines between cards + +--- + +#### Phase 5: Column Headers & Search + +**Goal:** Restyle per-column headers to match screenshot aesthetic, add rounded pill search bar, segmented feed toggles. + +**Files:** + +| File | Action | +|------|--------| +| `desktopApp/.../ui/deck/ColumnHeader.kt` | Restyle: height, background, typography | +| `desktopApp/.../ui/deck/FeedScreen.kt` (or equivalent) | Add SegmentedButton for My Feed / Global toggle | +| Search composable | Rounded pill styling | + +**ColumnHeader changes:** +```kotlin +// Before: surfaceVariant.copy(alpha = 0.5f), height = 40.dp +// After: +Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.height(MaterialTheme.spacing.columnHeaderHeight) // 48dp +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // icon + title with titleMedium style + } +} +``` + +**Search bar (rounded pill):** +```kotlin +TextField( + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ), + leadingIcon = { Icon(Search) }, + trailingIcon = { Text("⌘K", style = labelSmall, color = onSurfaceVariant) }, + placeholder = { Text("Search notes, profiles, hashtags...") }, +) +``` + +**Feed toggles (SegmentedButton):** +```kotlin +SingleChoiceSegmentedButtonRow { + SegmentedButton(selected = isMine, onClick = { ... }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { + Text("My Feed") + } + SegmentedButton(selected = !isMine, onClick = { ... }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { + Text("Global") + } +} +``` + +> **Research insight:** SegmentedButton confirmed available and stable in CMP 1.10.3 (already used in AppDrawer.kt). At `MIN_COLUMN_WIDTH` (300dp), SegmentedButton + icon + close button is tight. Gate visibility on column width > 400dp. + +> **Keyboard shortcut:** Wire `Cmd/Ctrl+K` to focus the search column's text field in MenuBar (Main.kt). + +**Success criteria:** +- [ ] Column headers have consistent 48dp height with surfaceContainer background +- [ ] Search bar is rounded pill with keyboard shortcut hint (Cmd+K wired) +- [ ] Feed toggle uses M3 SegmentedButton (hidden in narrow columns < 400dp) + +--- + +#### Phase 6: Polish & Micro-interactions + +**Goal:** Add hover effects, skeleton loading, styled tooltips/dialogs/context menus/scrollbars/snackbars. + +**Files:** + +| File | Action | +|------|--------| +| New: `desktopApp/.../ui/theme/HoverModifiers.kt` | `Modifier.hoverHighlight()` (desktop-only, `onPointerEvent`) | +| New: `commons/.../ui/components/ShimmerPlaceholder.kt` | Skeleton shimmer (pure M3, shared) | +| `desktopApp/.../ui/note/NoteCard.kt` | Add hover effect | +| `desktopApp/.../ui/deck/DeckSidebar.kt` | Add hover to nav items | +| Dialog composables | Surface color in dark mode | +| DropdownMenu usages | Styled corners + border | + +**Hover utility (uses `onPointerEvent` — codebase convention from AppDrawer.kt, ChatPane.kt):** + +> **Research fix:** Original plan used `composed{}` + `@Composable` lambda in `drawBehind` — won't compile. `shape.topStart` is invalid on generic `Shape`. Rewritten to match project patterns. + +```kotlin +// HoverModifiers.kt (desktopApp/jvmMain — desktop-only, uses ExperimentalComposeUiApi) +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun Modifier.hoverHighlight( + hoverColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + shape: Shape = MaterialTheme.shapes.medium, +): Modifier { + val color = remember { mutableStateOf(Color.Transparent) } + return this + .onPointerEvent(PointerEventType.Enter) { color.value = hoverColor } + .onPointerEvent(PointerEventType.Exit) { color.value = Color.Transparent } + .drawBehind { drawRect(color.value) } + // drawBehind reads color.value in draw phase only — no recomposition on hover +} +``` + +> **Performance note:** State read deferred to draw phase via `drawBehind`. No recomposition on hover enter/exit — only draw invalidation. Safe for LazyColumn with 50+ cards. + +**Skeleton shimmer:** +```kotlin +// ShimmerPlaceholder.kt +@Composable +fun ShimmerPlaceholder(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition() + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)), + ) + val brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + ), + start = Offset(translateAnim - 500f, 0f), + end = Offset(translateAnim, 0f), + ) + Box(modifier.background(brush, MaterialTheme.shapes.medium)) +} + +@Composable +fun NoteCardSkeleton() { + OutlinedCard( + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.medium, + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ShimmerPlaceholder(Modifier.size(32.dp).clip(CircleShape)) // avatar + Column(Arrangement.spacedBy(4.dp)) { + ShimmerPlaceholder(Modifier.width(120.dp).height(12.dp)) // name + ShimmerPlaceholder(Modifier.width(60.dp).height(10.dp)) // timestamp + } + } + ShimmerPlaceholder(Modifier.fillMaxWidth().height(14.dp)) // text line 1 + ShimmerPlaceholder(Modifier.fillMaxWidth(0.7f).height(14.dp)) // text line 2 + ShimmerPlaceholder(Modifier.fillMaxWidth().height(180.dp)) // image + } + } +} +``` + +**Dialog styling (dark mode):** + +> **Research fix:** `ColorScheme.isLight` does not exist in M3. Use `LocalIsDarkTheme` from Phase 2. + +```kotlin +// Wrap existing Dialog composables +Dialog(onDismissRequest = ...) { + Surface( + color = if (LocalIsDarkTheme.current) + MaterialTheme.colorScheme.surfaceContainerHighest + else + MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.large, // 16dp + tonalElevation = 6.dp, + ) { ... } +} +``` + +**Styled context menus:** +```kotlin +DropdownMenu( + modifier = Modifier + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.medium) + .clip(MaterialTheme.shapes.medium), + ... +) +``` + +**Styled tooltips:** +```kotlin +TooltipBox( + tooltip = { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = MaterialTheme.shapes.small, + shadowElevation = 2.dp, + ) { + Text(text, modifier = Modifier.padding(8.dp, 4.dp), style = labelSmall) + } + }, + ... +) +``` + +**Snackbar (extract shared composable — two SnackbarHost instances exist: Main.kt and ChatPane.kt):** +```kotlin +// commons/commonMain — pure M3, no platform APIs +@Composable +fun AmethystSnackbarHost( + hostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + SnackbarHost(hostState, modifier) { data -> + Snackbar( + snackbarData = data, + shape = MaterialTheme.shapes.medium, + containerColor = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + actionColor = MaterialTheme.colorScheme.primary, + ) + } +} +``` + +> **Research insight:** Plan originally used `surfaceContainerHighest` — may lack WCAG AA contrast in light mode. `inverseSurface`/`inverseOnSurface` provides high contrast in both modes (M3 standard snackbar pattern). + +**Scrollbar styling (via built-in `LocalScrollbarStyle`):** +```kotlin +// Provide at theme root in PlatformMaterialTheme +CompositionLocalProvider( + LocalScrollbarStyle provides ScrollbarStyle( + minimalHeight = 48.dp, thickness = 4.dp, + shape = RoundedCornerShape(2.dp), hoverDurationMillis = 300, + unhoverColor = Color.Black.copy(alpha = 0.12f), + hoverColor = Color.Black.copy(alpha = 0.50f), + ) +) { ... } +``` + +**Success criteria:** +- [ ] Cards highlight on hover +- [ ] Sidebar items highlight on hover +- [ ] Skeleton shimmer shown during feed loading +- [ ] Dialogs use elevated surface in dark mode +- [ ] Context menus have rounded corners + border +- [ ] Tooltips are styled +- [ ] Snackbar matches brand + +--- + +## System-Wide Impact + +### Interaction Graph + +Theme changes cascade through `PlatformMaterialTheme` → all `MaterialTheme.colorScheme.*` / `.shapes.*` / `.typography.*` consumers. No callbacks or middleware — purely declarative recomposition. + +### Error Propagation + +No new error paths. Theme resolution is pure function (no IO). Sidebar collapse preference uses `java.util.prefs.Preferences` (already used for custom feeds — failure falls back to expanded). + +### State Lifecycle Risks + +- Sidebar collapse state: stored in `Preferences`, read once at composition. No partial state risk. +- Feed definitions: already managed by `FeedDefinitionRepository` with `StateFlow`. Sidebar reads same flow. +- Account avatar: loaded from `LocalCache` metadata — may be null initially (fallback to icon). + +### API Surface Parity + +No external API changes. All changes are internal UI. + +### Integration Test Scenarios + +1. **Dark/light toggle:** Switch modes mid-session → all colors update (no cached old scheme) +2. **Sidebar collapse + window resize:** Collapse sidebar, resize window narrow → columns should not clip +3. **Custom feed CRUD:** Create/delete feed → sidebar FEEDS section updates live +4. **Account switch:** Switch account → avatar + username in sidebar update +5. **Feed loading:** Navigate to a feed → skeleton shimmer shows → cards render + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Unified Amethyst color scheme (cyan/blue accent) on all platforms +- [ ] Dark/light mode fully functional with proper contrast +- [ ] 240dp sidebar with icon + label navigation items +- [ ] Sidebar collapse/expand with smooth animation +- [ ] Collapse state persists across app restarts +- [ ] Avatar + username at top of sidebar with account switcher +- [ ] Custom feeds section in sidebar reading from FeedDefinitionRepository +- [ ] Flat cards with subtle border (no shadow) +- [ ] 16dp card padding, 12dp card corners +- [ ] Action bar with smaller icons and count badges +- [ ] Rounded images with 8dp corners +- [ ] Per-column headers with consistent 48dp height +- [ ] Rounded pill search bar with keyboard shortcut hint +- [ ] M3 SegmentedButton for feed toggles +- [ ] Hover effects on cards and sidebar items +- [ ] Skeleton shimmer during feed loading +- [ ] Styled tooltips, context menus, dialogs, snackbars + +### Non-Functional Requirements + +- [ ] App compiles and runs on macOS, Windows, Linux +- [ ] No regression in existing functionality +- [ ] Font rendering quality maintained (per-OS fonts preserved) +- [ ] Smooth animations (sidebar collapse, hover) at 60fps + +### Quality Gates + +- [ ] `./gradlew :desktopApp:compileKotlin` passes +- [ ] `./gradlew spotlessApply` clean +- [ ] Manual visual QA in dark and light modes +- [ ] Sidebar collapse/expand tested + +## Dependencies & Prerequisites + +- No external library additions — everything uses existing Material3 + Compose APIs +- `FeedDefinitionRepository` and custom feeds infrastructure already exist +- `AccountSwitcherDropdown` already exists — needs restyling, not rewriting +- Material Symbols font subset may need new icons for feed types (run `./tools/material-symbols-subset/subset.sh`) + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|-----------| +| Color contrast issues in dark mode | Medium | Medium | Test with accessibility contrast checker | +| Sidebar animation jank on Linux | Low | Low | Use `tween(250)` easing, test on GNOME | +| Breaking existing hover behavior | Low | Medium | Audit all `pointerInput` / `onPointerEvent` usages first | +| Missing Material Symbol icons | Medium | Low | Run subset script, add needed codepoints | +| Inline Color() literals bypassing theme | Medium | Low | Grep audit + replace in Phase 1 | + +## Migration Checklist + +- [ ] Remove per-OS color scheme functions (macOsLight, gnomeDark, etc.) +- [ ] Remove `PlatformAccent` accent resolution (no longer needed) +- [ ] Remove per-OS shape variants +- [ ] Grep for `Color(0xFF` in `desktopApp/` — replace with theme references +- [ ] Remove `darkenForLight()` helper +- [ ] Update any tests referencing old color values + +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md](docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md) — Key decisions: cyan/blue accent (#0096FF), flat+border cards, 240dp collapsible sidebar, Amethyst-branded over OS-native + +### Internal References + +- `PlatformColorScheme.kt` — current per-OS scheme implementation +- `PlatformTheme.kt` — theme composition entry point +- `DeckSidebar.kt` — current 56dp icon sidebar +- `NoteCard.kt` — current card styling +- `ColumnHeader.kt` — current column header (40dp, surfaceVariant) +- `AccountSwitcherDropdown.kt` — existing account switcher +- `FeedDefinitionRepository.kt` — custom feed data source +- `FeedsDrawerTab.kt` — current feed browsing UI + +### External References + +- [Material Design 3 Color System](https://m3.material.io/styles/color/system/overview) +- [Material Design 3 Elevation](https://m3.material.io/styles/elevation/applying-elevation) +- [Custom Design Systems in Compose](https://developer.android.com/develop/ui/compose/designsystems/custom) diff --git a/docs/plans/2026-05-28-fix-feed-header-search-ux-plan.md b/docs/plans/2026-05-28-fix-feed-header-search-ux-plan.md new file mode 100644 index 0000000000..f23cb69076 --- /dev/null +++ b/docs/plans/2026-05-28-fix-feed-header-search-ux-plan.md @@ -0,0 +1,175 @@ +--- +title: "fix: Feed Header Bar Layout + Inline Search UX" +type: fix +status: active +date: 2026-05-28 +origin: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md +--- + +# fix: Feed Header Bar Layout + Inline Search UX + +## Overview + +Fix 5 issues with the feed header bar and search experience introduced in the visual personality overhaul. + +## Problems + +| # | Issue | Root Cause | +|---|-------|-----------| +| 1 | Feed tabs take too much space, search not centered | `Row` with `SpaceBetween` pushes search to far right. Tabs + "+ More" consume all left space | +| 2 | Clicking search pill doesn't open search | `onSearchClick` callback not wired from `DeckColumnContainer` → `FeedScreen` → `FeedTabsHeader` | +| 3 | Search opens as Dialog overlay (wrong UX) | `SearchSpotlight` uses `Dialog()` which creates separate AWT window. Should be inline expansion of the pill with dropdown + background blur | +| 4 | Follow tabs can't be clicked | FilterChip `onClick` calls `onNavigateToFeed` for custom feeds, but the callback only handles `FeedSource.Filter` — other source types silently ignored | +| 5 | Header doesn't match screenshot card aesthetic | Header is a plain `Row` with padding. Should be a card-like `Surface` with rounded corners and visual separation | + +## Proposed Solution + +### Fix 1: Redesign FeedTabsHeader layout + +**File:** `FeedScreen.kt` — `FeedTabsHeader` + +New layout — compact tabs on left, search pill takes center weight, compose on right: +``` +┌─────────────────────────────────────────────────────┐ +│ [Following][Global][+] 🔍 Search notes... ⌘F ✏️ │ +└─────────────────────────────────────────────────────┘ +``` + +- Feed tabs: compact `FilterChip` with just emoji+name, `Arrangement.spacedBy(4.dp)` +- Remove "+ More" chip (moved to sidebar already) +- SearchPill: `Modifier.weight(1f)` so it fills remaining center space +- Compose button: stays on far right +- Wrap entire header in `Surface(shape = shapes.medium, color = surfaceContainer)` with 12dp padding + +### Fix 2: Wire onSearchClick callback + +**File:** `DeckColumnContainer.kt` + +Pass `onSearchClick = { showSearchSpotlight = true }` through `FeedScreen` call sites. The `showSearchSpotlight` state is already at Main.kt level — need to pass it down as callback. + +Simplest approach: add `onSearchClick` param to `MainContent` → column container → FeedScreen. + +### Fix 3: Replace Dialog with inline search expansion + +**File:** `SearchSpotlight.kt` → Delete. Replace with inline expansion in `SearchPill.kt` + +Instead of a Dialog overlay, the SearchPill itself expands: + +**Collapsed (default):** +``` +🔍 Search notes, profiles... ⌘F +``` + +**Expanded (on click or Cmd+F):** +``` +┌────────────────────────────────────────┐ +│ 🔍 [typing here...] ⌘F │ +├────────────────────────────────────────┤ +│ Recent │ +│ 📝 bitcoin lightning │ +│ 📝 @fiatjaf │ +├────────────────────────────────────────┤ +│ Saved │ +│ ⭐ Nostr development │ +└────────────────────────────────────────┘ ++ background blur/dim behind dropdown +``` + +Implementation: +- `SearchPill` gains `expanded: Boolean` state +- When expanded: pill becomes `BasicTextField` with same shape, a `DropdownMenu` or `Popup` appears below with history/results +- Background: `Box(Modifier.fillMaxSize().background(Color.Black.copy(0.3f)))` rendered at the parent level when expanded +- Clicking outside or pressing Escape collapses +- `Cmd+F` sets expanded = true on the active feed column's SearchPill + +### Fix 4: Fix follow tab click handlers + +**File:** `FeedScreen.kt` — `FeedTabsHeader` + +Current `onNavigateToFeed` callback only handles `FeedSource.Filter`. Need to also handle `FeedSource.Following` and `FeedSource.Global` by calling `onFeedModeChange` directly in the chip onClick (already done for the `when` branches — the bug is that custom feed chips with non-Filter sources silently do nothing). + +Check: is `feed.source` ever something other than `Following`, `Global`, or `Filter`? If so, add handling. + +### Fix 5: Card-based header design + +**File:** `FeedScreen.kt` — `FeedTabsHeader` + +Wrap the header Row in a `Surface`: +```kotlin +Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding, vertical = 8.dp), +) { + Row( + modifier = Modifier.padding(8.dp), + ... + ) { ... } +} +``` + +This gives it the same card treatment as feed items — white surface, subtle border, rounded corners. + +## Implementation Phases + +### Phase 1: Fix header layout + card design (Fixes 1, 5) + +**FeedScreen.kt — rewrite FeedTabsHeader:** +- Wrap in `Surface` with `OutlinedCard` styling +- Compact tabs (remove "+ More") +- SearchPill with `weight(1f)` in center +- 48dp height, consistent padding + +### Phase 2: Inline search expansion (Fixes 2, 3) + +**Delete SearchSpotlight.kt** — replace with expanded state in SearchPill. + +**New SearchPill.kt:** +```kotlin +@Composable +fun SearchPill( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onOpenFullSearch: (String) -> Unit, + modifier: Modifier = Modifier, +) +``` +- Collapsed: clickable Surface pill (current design) +- Expanded: `BasicTextField` in same pill shape + `Popup`/`DropdownMenu` below with history +- Scrim: parent renders a semi-transparent overlay when `expanded = true` + +**Main.kt:** +- `Cmd+F` sets a `searchExpanded` state that's passed down to the active feed +- Remove `SearchSpotlight` rendering + +### Phase 3: Fix tab click handlers (Fix 4) + +**FeedScreen.kt:** Audit `onNavigateToFeed` callback path. Ensure all `FeedSource` types are handled. + +## Acceptance Criteria + +- [ ] Feed tabs are compact, search pill fills remaining center space +- [ ] Header wrapped in card-like Surface matching screenshot aesthetic +- [ ] Clicking SearchPill expands it into an input with dropdown below +- [ ] Cmd+F expands the search pill (no separate overlay) +- [ ] Typing in expanded search shows recent + saved searches +- [ ] Clicking outside or pressing Escape collapses search +- [ ] All feed tabs (Following, Global, custom) are clickable and switch feeds +- [ ] "+ More" removed from header (already in sidebar) +- [ ] Compiles, spotless clean + +## Files Affected + +| File | Action | +|------|--------| +| `desktopApp/.../ui/FeedScreen.kt` | Rewrite `FeedTabsHeader` — layout, card design, tab fix | +| `desktopApp/.../ui/search/SearchPill.kt` | Add expanded state, inline BasicTextField, dropdown | +| `desktopApp/.../ui/search/SearchSpotlight.kt` | Delete (replaced by inline expansion) | +| `desktopApp/.../Main.kt` | Remove SearchSpotlight rendering, wire Cmd+F to feed search expansion | + +## Sources + +- Brainstorm: `docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md` +- Deepen research: Dialog creates separate AWT window on desktop — use Box overlay or inline instead +- Existing pattern: `LightboxOverlay.kt` uses Box overlay (not Dialog) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt index 06672d7b49..4fc6694b63 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.nip71Video import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -38,6 +41,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag /** * Contains the IMeta tags that are used by Video events. @@ -73,3 +77,19 @@ fun IMetaTagBuilder.summary(summary: HexKey) = add(SummaryTag.TAG_NAME, summary) fun IMetaTagBuilder.fallback(fallback: HexKey) = add(FallbackTag.TAG_NAME, fallback) fun IMetaTagBuilder.service(service: HexKey) = add(ServiceTag.TAG_NAME, service) + +fun IMetaTagBuilder.bitrate(bitsPerSecond: Int) = add(BitrateTag.TAG_NAME, bitsPerSecond.toString()) + +fun IMetaTagBuilder.duration(seconds: Double) = add(DurationTag.TAG_NAME, seconds.toString()) + +fun IMetaTagBuilder.duration(seconds: Int) = add(DurationTag.TAG_NAME, seconds.toString()) + +fun IMetaTagBuilder.waveform(wave: List) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWaveFloat(wave)) + +fun IMetaTagBuilder.language(language: LanguageImetaTag) = add(LanguageImetaTag.TAG_NAME, language.toPropertyValue()) + +fun IMetaTagBuilder.language( + code: String, + standard: String = LanguageImetaTag.DEFAULT_STANDARD, + originalVersion: Boolean = false, +) = language(LanguageImetaTag(code, standard, originalVersion)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt index 42d0e2addd..714dfc7d97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.nip71Video import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -37,6 +40,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag /** * Contains the IMeta tags that are used by Video events. @@ -72,3 +76,18 @@ fun IMetaTag.summary() = properties.get(SummaryTag.TAG_NAME) fun IMetaTag.fallback() = properties.get(FallbackTag.TAG_NAME) fun IMetaTag.service() = properties.get(ServiceTag.TAG_NAME) + +fun IMetaTag.bitrate(): Int? = properties.get(BitrateTag.TAG_NAME)?.firstOrNull()?.toIntOrNull() + +fun IMetaTag.duration(): Double? = properties.get(DurationTag.TAG_NAME)?.firstOrNull()?.toDoubleOrNull() + +fun IMetaTag.waveform(): List? = properties.get(WaveformTag.TAG_NAME)?.firstOrNull()?.let { WaveformTag.parseWave(it) } + +fun IMetaTag.language(): LanguageImetaTag? = properties.get(LanguageImetaTag.TAG_NAME)?.firstNotNullOfOrNull { LanguageImetaTag.parseValue(it) } + +// NIP-71 PR #2255: audio-only variants are advertised as `imeta` entries +// with an `m audio/...` property so clients can prefer them over in-video +// audio while switching video resolution. +fun IMetaTag.isAudioTrack(): Boolean = mimeType()?.firstOrNull()?.startsWith("audio/") == true + +fun IMetaTag.isVideoTrack(): Boolean = mimeType()?.firstOrNull()?.startsWith("video/") == true diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt index efd1c8c032..f0003229f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt @@ -47,4 +47,17 @@ interface VideoEvent : IEvent { fun hash(): String? fun imetaTags(): List + + /** + * NIP-71 PR #2255: imeta entries whose mime type starts with `video/` + * (or entries without a mime type, treated as legacy video variants). + */ + fun videoTracks(): List = imetaTags().filter { it.isVideo || it.mimeType == null } + + /** + * NIP-71 PR #2255: imeta entries whose mime type starts with `audio/`. + * Clients should prefer these over in-video audio to enable smooth + * resolution switching without interrupting audio. + */ + fun audioTracks(): List = imetaTags().filter { it.isAudio } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt index 66d4d334c0..967d3cf114 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip71Video +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -36,7 +37,18 @@ data class VideoMeta( val fallback: List = emptyList(), val image: List = emptyList(), val thumbhash: String? = null, + // NIP-71 PR #2255 — audio-track imeta properties + val bitrate: Int? = null, + val duration: Double? = null, + val waveform: List? = null, + val language: LanguageImetaTag? = null, ) { + val isAudio: Boolean + get() = mimeType?.startsWith("audio/") == true + + val isVideo: Boolean + get() = mimeType?.startsWith("video/") == true + fun toIMetaArray(): Array = IMetaTagBuilder(url) .apply { @@ -50,6 +62,10 @@ data class VideoMeta( service?.let { service(it) } fallback.forEach { fallback(it) } image.forEach { image(it) } + bitrate?.let { bitrate(it) } + duration?.let { duration(it) } + waveform?.let { waveform(it) } + language?.let { language(it) } }.build() .toTagArray() @@ -67,6 +83,10 @@ data class VideoMeta( fallback = iMeta.fallback() ?: emptyList(), image = iMeta.image() ?: emptyList(), thumbhash = iMeta.thumbhash()?.firstOrNull(), + bitrate = iMeta.bitrate(), + duration = iMeta.duration(), + waveform = iMeta.waveform(), + language = iMeta.language(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt new file mode 100644 index 0000000000..1b82711f9a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.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.nip71Video.tags + +class BitrateTag { + companion object { + const val TAG_NAME = "bitrate" + + fun assemble(bitsPerSecond: Int) = arrayOf(TAG_NAME, bitsPerSecond.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt new file mode 100644 index 0000000000..2926e1c85f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.tags + +import androidx.compose.runtime.Immutable + +/** + * NIP-71 audio-track language tag (lives inside an `imeta` tag). + * + * Format: `l [ov]` + * + * - `code` — language code, e.g. `en` + * - `standard` — namespace, e.g. `ISO-639-1` (default when missing) + * - `ov` — optional flag marking the original-version track + */ +@Immutable +data class LanguageImetaTag( + val code: String, + val standard: String = DEFAULT_STANDARD, + val originalVersion: Boolean = false, +) { + fun toPropertyValue(): String = + buildString { + append(code) + append(' ') + append(standard) + if (originalVersion) { + append(' ') + append(ORIGINAL_VERSION_MARKER) + } + } + + companion object { + const val TAG_NAME = "l" + const val DEFAULT_STANDARD = "ISO-639-1" + const val ORIGINAL_VERSION_MARKER = "ov" + + fun parseValue(value: String): LanguageImetaTag? { + val parts = value.split(' ').filter { it.isNotEmpty() } + if (parts.isEmpty()) return null + val code = parts[0] + val standard = parts.getOrNull(1) ?: DEFAULT_STANDARD + val ov = parts.drop(2).any { it == ORIGINAL_VERSION_MARKER } + return LanguageImetaTag(code, standard, ov) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt new file mode 100644 index 0000000000..af8a560efe --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppDataEvent.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip78AppData + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-78 — Arbitrary custom app data, normal-event flavor. + * + * Kind 78 is the non-replaceable counterpart to [AppSpecificDataEvent] (kind + * 30078). Use it when an app needs to store and query multiple events of the + * same type. The spec recommends using unique tags (including `d` tags) for + * grouping related events; the `d` tag here does NOT make the event + * addressable, it is just a free-form grouping key. + */ +@Immutable +class AppDataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun dTag(): String = tags.dTag() + + companion object { + const val KIND = 78 + const val ALT = "Arbitrary app data" + + fun build( + content: String, + dTag: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT) + dTag?.let { addUnique(DTag.assemble(it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt index 4d6b403d2d..85c4d9e290 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt @@ -23,9 +23,11 @@ package com.vitorpamplona.quartz.nip78AppData import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils class AppSpecificDataEvent( @@ -52,28 +54,15 @@ class AppSpecificDataEvent( dTag: String, ): ATag = ATag(KIND, pubkey, dTag, null) - suspend fun create( + fun build( dTag: String, description: String, - otherTags: Array>, - signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): AppSpecificDataEvent { - val withD = - if (otherTags.any { it.size > 1 && it[0] == "d" && it[1] == dTag }) { - otherTags - } else { - otherTags.filter { it.size > 0 && it[0] != "d" }.toTypedArray() + arrayOf("d", dTag) - } - - val newTags = - if (withD.none { it.size > 0 && it[0] == "alt" }) { - withD + AltTag.assemble(ALT) - } else { - withD - } - - return signer.sign(createdAt, KIND, newTags, description) + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + alt(ALT) + dTag(dTag) + initializer() } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt index 6f308d9b8a..0c3c440769 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA4PublicMessages/PublicMessageEvent.kt @@ -24,10 +24,18 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.any +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag.Companion.parseAsHint import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip19Bech32.addressHints +import com.vitorpamplona.quartz.nip19Bech32.addressIds import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.eventHints +import com.vitorpamplona.quartz.nip19Bech32.eventIds import com.vitorpamplona.quartz.nip19Bech32.pubKeyHints import com.vitorpamplona.quartz.nip19Bech32.pubKeys import com.vitorpamplona.quartz.nip31Alts.alt @@ -45,6 +53,8 @@ class PublicMessageEvent( sig: HexKey, ) : BaseNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig), PubKeyHintProvider, + EventHintProvider, + AddressHintProvider, SearchableEvent { override fun indexableContent() = content @@ -52,6 +62,14 @@ class PublicMessageEvent( override fun linkedPubKeys() = tags.mapNotNull(ReceiverTag::parseKey) + citedNIP19().pubKeys() + override fun eventHints() = citedNIP19().eventHints() + + override fun linkedEventIds() = citedNIP19().eventIds() + + override fun addressHints() = citedNIP19().addressHints() + + override fun linkedAddressIds() = citedNIP19().addressIds() + fun isIncluded(pubKey: HexKey) = tags.any(ReceiverTag::match, pubKey) || this.pubKey == pubKey fun group() = tags.mapNotNullTo(mutableListOf(ReceiverTag(this.pubKey, null)), ReceiverTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt new file mode 100644 index 0000000000..abf626b368 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/AuthoredPodcastsEvent.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.authored + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-F4 author counter-claim (`kind:10064`). A user authors this to assert which + * podcast pubkeys they actually create — used by clients to confirm that the + * authorship claim made by a [com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent] + * is reciprocated by the alleged author, and to discover all podcasts authored + * by a given user. + */ +@Immutable +class AuthoredPodcastsEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + fun authored() = tags.mapNotNull(UserTag::parse) + + fun authoredKeys() = tags.mapNotNull(UserTag::parseKey) + + fun authors(podcastPubKey: HexKey) = tags.any { UserTag.isTagged(it, podcastPubKey) } + + override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey) + + override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint) + + companion object { + const val KIND = 10064 + const val ALT_DESCRIPTION = "List of authored podcasts" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun build( + podcasts: List, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + podcasts.forEach { podcast(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..5c366ded4f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/authored/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.authored + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag + +fun TagArrayBuilder.podcast(podcast: UserTag) = add(podcast.toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt new file mode 100644 index 0000000000..5afb2fd4dd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.episode + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-F4 podcast episode (`kind:54`), regular event authored by the podcast pubkey. + * + * `content` is the markdown-formatted episode notes. Title, image, short description, + * and one or more audio URLs live in tags so clients can render listings without + * parsing markdown. + */ +@Immutable +class PodcastEpisodeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun audios() = tags.mapNotNull(AudioTag::parse) + + companion object { + const val KIND = 54 + const val ALT_DESCRIPTION_PREFIX = "Podcast episode" + + fun build( + title: String, + description: String, + audios: List, + markdownContent: String = "", + image: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, markdownContent, createdAt) { + alt("$ALT_DESCRIPTION_PREFIX: $title") + + title(title) + description(description) + image?.let { image(it) } + audios.forEach { audio(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..7d98b5560f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/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.nipF4Podcasts.episode + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.audio(audio: AudioTag) = add(AudioTag.assemble(audio)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt new file mode 100644 index 0000000000..d1aec68bde --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/AudioTag.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.episode.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-F4 episode audio tag: `["audio", "", ""]`. + * + * Multiple `audio` tags are allowed — typically used to provide the same episode + * in different containers/codecs (e.g. mp3 + opus). When present, [mediaType] is + * a MIME type the client can use to pick a stream it can decode without HEAD-ing + * the URL first. + */ +@Stable +class AudioTag( + val url: String, + val mediaType: String? = null, +) { + fun toTagArray() = assemble(url, mediaType) + + companion object { + const val TAG_NAME = "audio" + + fun parse(tag: Array): AudioTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } + return AudioTag(tag[1], mediaType) + } + + fun assemble( + url: String, + mediaType: String? = null, + ): Array = + if (mediaType.isNullOrEmpty()) { + arrayOf(TAG_NAME, url) + } else { + arrayOf(TAG_NAME, url, mediaType) + } + + fun assemble(audio: AudioTag) = assemble(audio.url, audio.mediaType) + + fun assemble(audios: List) = audios.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt new file mode 100644 index 0000000000..de1eb3154a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/DescriptionTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + 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(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt new file mode 100644 index 0000000000..9057dbcbd1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + 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(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt new file mode 100644 index 0000000000..fcd3bca442 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + 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/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt new file mode 100644 index 0000000000..e492348c9f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nipF4Podcasts.favorites + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-F4 user favorite-podcasts list (`kind:10054`). Per the spec this advertises + * the podcasts the user listens to as a soft public recommendation. Items are + * `p` tags pointing at podcast pubkeys. + * + * Follows the NIP-51 convention of also allowing private items stored encrypted + * in `content`, so a user can keep guilty pleasures off the public list while + * still using the same event kind for their own subscription state. + */ +@Immutable +class FavoritePodcastsListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicFavorites() = tags.mapNotNull(UserTag::parse) + + suspend fun privateFavorites(signer: NostrSigner) = privateTags(signer)?.mapNotNull(UserTag::parse) + + companion object { + const val KIND = 10054 + const val ALT = "Favorite podcasts list" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "") + + suspend fun create( + publicFavorites: List = emptyList(), + privateFavorites: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoritePodcastsListEvent = + resign( + content = PrivateTagsInContent.encryptNip44(privateFavorites.map { it.toTagArray() }.toTypedArray(), signer), + tags = publicFavorites.map { it.toTagArray() }.toTypedArray(), + signer = signer, + createdAt = createdAt, + ) + + suspend fun add( + earlierVersion: FavoritePodcastsListEvent, + podcast: UserTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoritePodcastsListEvent = + if (isPrivate) { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + // MUST strip from public too — a podcast that was previously public and is now + // being made private must not stay visible in the unencrypted tag list. + resign( + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()), + privateTags = privateTags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } else { + // Symmetric: strip from private tags too when moving a previously-private + // podcast to the public list, otherwise it sits in both halves. + val privateTags = earlierVersion.privateTags(signer) + if (privateTags != null) { + resign( + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), + privateTags = privateTags.remove(podcast.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun remove( + earlierVersion: FavoritePodcastsListEvent, + podcast: UserTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoritePodcastsListEvent { + val idOnly = podcast.toTagIdOnly() + val privateTags = earlierVersion.privateTags(signer) + return if (privateTags != null) { + resign( + privateTags = privateTags.remove(idOnly), + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoritePodcastsListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + return signer.sign(createdAt, KIND, newTags, content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt new file mode 100644 index 0000000000..44ad1fd43b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.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.quartz.nipF4Podcasts.metadata + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-F4 show-level metadata for a podcast. Authored by the podcast's own pubkey + * (each podcast is its own Nostr keypair). Replaceable: each new event of this + * kind replaces the previous version for that pubkey. + * + * Podcast-specific clients SHOULD read from [PodcastMetadataEvent] directly and + * ignore the podcast's `kind:0` profile, per spec — the show name and cover art + * shipped here are the canonical ones for podcast UIs even if the same pubkey + * also publishes a microblogging profile. + */ +@Immutable +class PodcastMetadataEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun websites() = tags.mapNotNull(WebsiteTag::parse) + + /** + * Returns claimed authors and their roles. The spec warns these claims are + * unverified — a podcast can name anyone as author. Before surfacing an author + * to users, cross-check with an [com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent] + * counter-claim signed by that author's own key. + */ + fun claimedAuthors() = tags.mapNotNull(AuthorTag::parse) + + companion object { + const val KIND = 10154 + const val ALT_DESCRIPTION = "Podcast metadata" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun build( + title: String, + image: String, + description: String, + websites: List = emptyList(), + authors: List = emptyList(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + + title(title) + image(image) + description(description) + + websites.forEach { website(it) } + authors.forEach { author(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..5948af9138 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/TagArrayBuilderExt.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.metadata + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.website(url: String) = add(WebsiteTag.assemble(url)) + +fun TagArrayBuilder.author(author: AuthorTag) = add(AuthorTag.assemble(author)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt new file mode 100644 index 0000000000..b8a05e88e8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-F4 author p-tag: `["p", "", ""]`. + * + * Role is optional and, per spec, may be `"host"`, `"cohost"`, or `"editor"`. + * Unknown role strings are preserved verbatim so future role values round-trip; + * clients should validate against [Role] before displaying. + * + * NOTE: this tag overloads `p`'s third slot for role, NOT the usual relay hint. + * Don't reuse this parser on p-tags from other event kinds. + */ +@Stable +class AuthorTag( + val pubKey: HexKey, + val role: String? = null, +) { + fun toTagArray() = assemble(pubKey, role) + + companion object { + const val TAG_NAME = "p" + + const val ROLE_HOST = "host" + const val ROLE_COHOST = "cohost" + const val ROLE_EDITOR = "editor" + + private val KNOWN_ROLES = setOf(ROLE_HOST, ROLE_COHOST, ROLE_EDITOR) + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Array): AuthorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + // NIP-F4 overloads slot 2 as role — but only host/cohost/editor are spec-defined. + // Anything else (notably a stale relay-hint URL copied from a kind:3 p-tag) gets + // dropped so it doesn't render as 'Role: wss://relay…' under the author chip. + val role = tag.getOrNull(2)?.takeIf { it in KNOWN_ROLES } + return AuthorTag(tag[1], role) + } + + fun assemble( + pubKey: HexKey, + role: String? = null, + ): Array = + if (role.isNullOrEmpty()) { + arrayOf(TAG_NAME, pubKey) + } else { + arrayOf(TAG_NAME, pubKey, role) + } + + fun assemble(author: AuthorTag) = assemble(author.pubKey, author.role) + + fun assemble(authors: List) = authors.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt new file mode 100644 index 0000000000..41e7707c99 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/DescriptionTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + 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(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt new file mode 100644 index 0000000000..d00c29ecc9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + 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(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt new file mode 100644 index 0000000000..32bfbd2817 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + 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/nipF4Podcasts/metadata/tags/WebsiteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt new file mode 100644 index 0000000000..95f187f6f7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/WebsiteTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class WebsiteTag { + companion object { + const val TAG_NAME = "website" + + 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(url: String) = arrayOf(TAG_NAME, url) + + fun assemble(urls: List) = urls.map { assemble(it) } + } +} 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 08ffa2caef..bc81ff54a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -218,6 +218,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppDataEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -291,6 +292,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent interface EventBuilder { fun build( @@ -322,6 +327,7 @@ class EventFactory { AppCurationSetEvent.KIND -> AppCurationSetEvent(id, pubKey, createdAt, tags, content, sig) AppDefinitionEvent.KIND -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig) AppRecommendationEvent.KIND -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + AppDataEvent.KIND -> AppDataEvent(id, pubKey, createdAt, tags, content, sig) AppSpecificDataEvent.KIND -> AppSpecificDataEvent(id, pubKey, createdAt, tags, content, sig) AttestationEvent.KIND -> AttestationEvent(id, pubKey, createdAt, tags, content, sig) AttestationRequestEvent.KIND -> AttestationRequestEvent(id, pubKey, createdAt, tags, content, sig) @@ -534,6 +540,10 @@ class EventFactory { ZapPollEvent.KIND -> ZapPollEvent(id, pubKey, createdAt, tags, content, sig) PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig) PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig) + PodcastMetadataEvent.KIND -> PodcastMetadataEvent(id, pubKey, createdAt, tags, content, sig) + PodcastEpisodeEvent.KIND -> PodcastEpisodeEvent(id, pubKey, createdAt, tags, content, sig) + AuthoredPodcastsEvent.KIND -> AuthoredPodcastsEvent(id, pubKey, createdAt, tags, content, sig) + FavoritePodcastsListEvent.KIND -> FavoritePodcastsListEvent(id, pubKey, createdAt, tags, content, sig) ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt new file mode 100644 index 0000000000..1826bf65ab --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nip71Video + +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class VideoAudioImetaTest { + /** + * NIP-71 PR #2255 spec example for an audio-only imeta variant. + */ + @Test + fun audioImetaRoundTripMatchesSpecExample() { + val waveform = + listOf( + 0f, + 7f, + 35f, + 8f, + 100f, + 100f, + 49f, + 8f, + 4f, + 16f, + 8f, + 10f, + 7f, + 2f, + 20f, + 10f, + 100f, + 100f, + 100f, + 100f, + ) + + val original = + VideoMeta( + url = "https://myaudio.com/audio/en/12345.mp3", + mimeType = "audio/mp3", + hash = "b2e0a7a82ac9f3f3a71f1d9a78c381d5be9d1cf19dce258765c17c8a76287c93", + service = "nip96", + fallback = + listOf( + "https://myotherserver.com/audio/en/12345.mp3", + "https://andanotherserver.com/audio/en/12345.mp3", + ), + bitrate = 320000, + duration = 29.24, + waveform = waveform, + language = LanguageImetaTag("en", "ISO-639-1", originalVersion = true), + ) + + val tag = original.toIMetaArray() + val parsed = IMetaTag.parse(tag)?.firstOrNull() + assertNotNull(parsed) + val round = VideoMeta.parse(parsed) + + assertEquals(original.url, round.url) + assertEquals("audio/mp3", round.mimeType) + assertEquals(original.hash, round.hash) + assertEquals("nip96", round.service) + assertEquals(original.fallback, round.fallback) + assertEquals(320000, round.bitrate) + assertEquals(29.24, round.duration) + assertEquals(waveform, round.waveform) + assertEquals(LanguageImetaTag("en", "ISO-639-1", originalVersion = true), round.language) + assertTrue(round.isAudio) + } + + @Test + fun audioAndVideoTracksAreSplitByMimeType() { + val video = + VideoMeta( + url = "https://example.com/video.mp4", + mimeType = "video/mp4", + ) + val audio = + VideoMeta( + url = "https://example.com/audio.mp3", + mimeType = "audio/mp3", + language = LanguageImetaTag("en"), + ) + + assertTrue(video.isVideo) + assertTrue(audio.isAudio) + } + + @Test + fun languageImetaTagParsesOriginalVersionFlag() { + val parsed = LanguageImetaTag.parseValue("en ISO-639-1 ov") + assertNotNull(parsed) + assertEquals("en", parsed.code) + assertEquals("ISO-639-1", parsed.standard) + assertTrue(parsed.originalVersion) + } + + @Test + fun languageImetaTagDefaultsStandardWhenMissing() { + val parsed = LanguageImetaTag.parseValue("pt") + assertNotNull(parsed) + assertEquals("pt", parsed.code) + assertEquals("ISO-639-1", parsed.standard) + assertEquals(false, parsed.originalVersion) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt new file mode 100644 index 0000000000..98cba1a688 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/AuthoredPodcastsEventTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AuthoredPodcastsEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val podcast1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val podcast2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + private val unrelated = "0000000000000000000000000000000000000000000000000000000000000001" + + @Test + fun `kind is 10064`() { + assertEquals(10064, AuthoredPodcastsEvent.KIND) + } + + @Test + fun `build round-trips podcast pubkeys`() { + val template = + AuthoredPodcastsEvent.build( + podcasts = listOf(UserTag(podcast1), UserTag(podcast2)), + ) + val event = signer.sign(template) + + assertEquals(listOf(podcast1, podcast2), event.authoredKeys()) + assertTrue(event.authors(podcast1)) + assertTrue(event.authors(podcast2)) + assertFalse(event.authors(unrelated)) + } + + @Test + fun `is replaceable with empty d-tag`() { + val template = AuthoredPodcastsEvent.build(emptyList()) + val event = signer.sign(template) + + assertEquals("", event.dTag()) + } + + @Test + fun `linkedPubKeys returns all authored podcast keys`() { + val template = + AuthoredPodcastsEvent.build( + podcasts = listOf(UserTag(podcast1), UserTag(podcast2)), + ) + val event = signer.sign(template) + + assertEquals(setOf(podcast1, podcast2), event.linkedPubKeys().toSet()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt new file mode 100644 index 0000000000..98a3570f3e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class FavoritePodcastsListEventTest { + private val signer = + NostrSignerInternal( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val podcast1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val podcast2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + + @Test + fun `kind is 10054`() { + assertEquals(10054, FavoritePodcastsListEvent.KIND) + } + + @Test + fun `create with public favorites round-trips`() = + runTest { + val event = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1), UserTag(podcast2)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val pubs = event.publicFavorites() + assertEquals(2, pubs.size) + assertEquals(podcast1, pubs[0].pubKey) + assertEquals(podcast2, pubs[1].pubKey) + } + + @Test + fun `create with private favorites hides them from public tags`() = + runTest { + val event = + FavoritePodcastsListEvent.create( + privateFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + assertFalse( + event.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 }, + "Private favorite must not be exposed as a public p-tag", + ) + assertTrue(event.content.isNotEmpty(), "Content must hold encrypted private tags") + + val priv = assertNotNull(event.privateFavorites(signer)) + assertEquals(1, priv.size) + assertEquals(podcast1, priv[0].pubKey) + } + + @Test + fun `add public favorite appends to list and dedupes by pubkey`() = + runTest { + val first = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val second = + FavoritePodcastsListEvent.add( + earlierVersion = first, + podcast = UserTag(podcast2), + isPrivate = false, + signer = signer, + createdAt = 1_700_000_001, + ) + + assertEquals( + listOf(podcast1, podcast2), + second.publicFavorites().map { it.pubKey }, + ) + + // Adding podcast1 again must not double it. + val third = + FavoritePodcastsListEvent.add( + earlierVersion = second, + podcast = UserTag(podcast1), + isPrivate = false, + signer = signer, + createdAt = 1_700_000_002, + ) + assertEquals( + listOf(podcast2, podcast1), + third.publicFavorites().map { it.pubKey }, + ) + } + + @Test + fun `moving public favorite to private strips the public p-tag`() = + runTest { + val publicFirst = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val nowPrivate = + FavoritePodcastsListEvent.add( + earlierVersion = publicFirst, + podcast = UserTag(podcast1), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_001, + ) + + assertFalse( + nowPrivate.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 }, + "Moving a public favorite to private must strip it from the unencrypted tag list", + ) + val priv = assertNotNull(nowPrivate.privateFavorites(signer)) + assertEquals(listOf(podcast1), priv.map { it.pubKey }) + } + + @Test + fun `moving private favorite to public strips the encrypted entry`() = + runTest { + val privateFirst = + FavoritePodcastsListEvent.create( + privateFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val nowPublic = + FavoritePodcastsListEvent.add( + earlierVersion = privateFirst, + podcast = UserTag(podcast1), + isPrivate = false, + signer = signer, + createdAt = 1_700_000_001, + ) + + assertTrue( + nowPublic.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 }, + "Promoted entry must be present as a public p-tag", + ) + val priv = assertNotNull(nowPublic.privateFavorites(signer)) + assertTrue(priv.isEmpty(), "Promoted entry must no longer be in the encrypted list") + } + + @Test + fun `remove drops the matching pubkey from public tags`() = + runTest { + val first = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1), UserTag(podcast2)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val second = + FavoritePodcastsListEvent.remove( + earlierVersion = first, + podcast = UserTag(podcast1), + signer = signer, + createdAt = 1_700_000_001, + ) + + assertEquals(listOf(podcast2), second.publicFavorites().map { it.pubKey }) + } + + @Test + fun `alt tag is always present`() = + runTest { + val event = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + assertTrue(event.tags.any { it[0] == "alt" && it[1] == FavoritePodcastsListEvent.ALT }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt new file mode 100644 index 0000000000..e841e19c7a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastEpisodeEventTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipF4Podcasts + +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastEpisodeEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `kind is 54`() { + assertEquals(54, PodcastEpisodeEvent.KIND) + } + + @Test + fun `build round-trips fields and content`() { + val markdown = "# Episode notes\n\nSome **bold** text." + val template = + PodcastEpisodeEvent.build( + title = "Ep 1", + description = "First episode", + audios = listOf(AudioTag("https://example.com/ep1.mp3", "audio/mpeg")), + markdownContent = markdown, + image = "https://example.com/ep1.png", + ) + val event = signer.sign(template) + + assertEquals("Ep 1", event.title()) + assertEquals("First episode", event.description()) + assertEquals("https://example.com/ep1.png", event.image()) + assertEquals(markdown, event.content) + + val audios = event.audios() + assertEquals(1, audios.size) + assertEquals("https://example.com/ep1.mp3", audios[0].url) + assertEquals("audio/mpeg", audios[0].mediaType) + } + + @Test + fun `image is optional`() { + val template = + PodcastEpisodeEvent.build( + title = "Ep 2", + description = "No artwork", + audios = listOf(AudioTag("https://example.com/ep2.mp3")), + ) + val event = signer.sign(template) + + assertNull(event.image()) + } + + @Test + fun `multiple audio tags are preserved with optional media type`() { + val template = + PodcastEpisodeEvent.build( + title = "Ep 3", + description = "Multi-codec", + audios = + listOf( + AudioTag("https://example.com/ep3.mp3", "audio/mpeg"), + AudioTag("https://example.com/ep3.opus", "audio/opus"), + AudioTag("https://example.com/ep3.unknown"), + ), + ) + val event = signer.sign(template) + + val audios = event.audios() + assertEquals(3, audios.size) + assertEquals("audio/mpeg", audios[0].mediaType) + assertEquals("audio/opus", audios[1].mediaType) + assertNull(audios[2].mediaType) + } + + @Test + fun `alt tag includes title`() { + val template = + PodcastEpisodeEvent.build( + title = "Ep Title", + description = "x", + audios = listOf(AudioTag("https://example.com/x.mp3")), + ) + val event = signer.sign(template) + + val alt = event.tags.first { it[0] == "alt" } + assertTrue(alt[1].endsWith("Ep Title")) + assertTrue(alt[1].startsWith(PodcastEpisodeEvent.ALT_DESCRIPTION_PREFIX)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt new file mode 100644 index 0000000000..1d9bd5bf91 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMetadataEventTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.nipF4Podcasts + +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastMetadataEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val authorPubKey1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + private val authorPubKey2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + + @Test + fun `kind is 10154`() { + assertEquals(10154, PodcastMetadataEvent.KIND) + } + + @Test + fun `build round-trips required fields`() { + val template = + PodcastMetadataEvent.build( + title = "Show Title", + image = "https://example.com/cover.png", + description = "A show about things.", + ) + val event = signer.sign(template) + + assertEquals("Show Title", event.title()) + assertEquals("https://example.com/cover.png", event.image()) + assertEquals("A show about things.", event.description()) + assertTrue(event.websites().isEmpty()) + assertTrue(event.claimedAuthors().isEmpty()) + } + + @Test + fun `build round-trips multiple websites and authors with roles`() { + val template = + PodcastMetadataEvent.build( + title = "Show", + image = "https://example.com/cover.png", + description = "Desc", + websites = listOf("https://show.example", "https://show.example/feed"), + authors = + listOf( + AuthorTag(authorPubKey1, AuthorTag.ROLE_HOST), + AuthorTag(authorPubKey2, AuthorTag.ROLE_COHOST), + ), + ) + val event = signer.sign(template) + + assertEquals( + listOf("https://show.example", "https://show.example/feed"), + event.websites(), + ) + + val authors = event.claimedAuthors() + assertEquals(2, authors.size) + assertEquals(authorPubKey1, authors[0].pubKey) + assertEquals(AuthorTag.ROLE_HOST, authors[0].role) + assertEquals(authorPubKey2, authors[1].pubKey) + assertEquals(AuthorTag.ROLE_COHOST, authors[1].role) + } + + @Test + fun `author tag without role parses with null role`() { + val template = + PodcastMetadataEvent.build( + title = "Show", + image = "https://example.com/cover.png", + description = "Desc", + authors = listOf(AuthorTag(authorPubKey1, null)), + ) + val event = signer.sign(template) + + val authors = event.claimedAuthors() + assertEquals(1, authors.size) + assertEquals(authorPubKey1, authors[0].pubKey) + assertNull(authors[0].role) + } + + @Test + fun `alt tag is emitted`() { + val template = + PodcastMetadataEvent.build( + title = "Show", + image = "https://example.com/cover.png", + description = "Desc", + ) + val event = signer.sign(template) + + assertTrue(event.tags.any { it[0] == "alt" && it[1] == PodcastMetadataEvent.ALT_DESCRIPTION }) + } + + @Test + fun `is replaceable with empty d-tag`() { + val template = + PodcastMetadataEvent.build( + title = "Show", + image = "https://example.com/cover.png", + description = "Desc", + ) + val event = signer.sign(template) + + assertEquals("", event.dTag()) + assertEquals(10154, event.address().kind) + assertEquals(event.pubKey, event.address().pubKeyHex) + } +} diff --git a/todos/001-pending-p2-feed-content-margin-under-search.md b/todos/001-pending-p2-feed-content-margin-under-search.md new file mode 100644 index 0000000000..206d10efb9 --- /dev/null +++ b/todos/001-pending-p2-feed-content-margin-under-search.md @@ -0,0 +1,34 @@ +--- +status: pending +priority: p2 +issue_id: "001" +tags: [code-review, ui, desktop, search] +--- + +# Feed content needs more margin below search bar + +## Problem Statement + +When the search bar is expanded, feed items below get partially hidden by the expanded search card. The spacer height (60dp) that reserves space for the header is not enough when the search card expands with history/results. + +## Findings + +- FeedScreen.kt line 587: `Spacer(Modifier.height(60.dp))` reserves space for the collapsed header only +- When search expands, the card grows downward but the feed content doesn't shift +- Feed items near the top get obscured by the expanded search card + scrim + +## Proposed Solutions + +**Option A: Dynamic spacer based on search state** +- When `searchActive`, increase spacer to match expanded card height (~300dp) +- Pros: exact spacing. Cons: needs to track card height dynamically. + +**Option B: Add extra bottom padding to expanded card** +- Feed items already have their own padding. Just ensure the search card's expanded area doesn't overlap. +- Pros: simple. Cons: may not cover all cases. + +## Acceptance Criteria + +- [ ] Feed items below the search bar are fully visible when search is collapsed +- [ ] When search expands, feed content is not obscured by the expanded card +- [ ] Smooth transition when expanding/collapsing diff --git a/todos/002-pending-p2-search-history-item-click-populates-input.md b/todos/002-pending-p2-search-history-item-click-populates-input.md new file mode 100644 index 0000000000..84b8a99ac3 --- /dev/null +++ b/todos/002-pending-p2-search-history-item-click-populates-input.md @@ -0,0 +1,34 @@ +--- +status: pending +priority: p2 +issue_id: "002" +tags: [code-review, ui, desktop, search] +--- + +# Tapping search history should populate input and search + +## Problem Statement + +Clicking a recent search item in the expanded search history currently just calls `onOpenFullSearch()` (opens full Search column). It should instead populate the search input with that query text and immediately start searching. + +## Findings + +- FeedScreen.kt SearchHistorySection: all history rows call `onOpenFullSearch()` on click +- Should instead: set `searchText` to the history item's text, which triggers `updateFromText()` and relay subscriptions + +## Proposed Solutions + +**Option A: Pass `onHistoryItemClick: (String) -> Unit` to SearchHistorySection** +- Callback sets `searchText = TextFieldValue(text)` in parent +- `LaunchedEffect(searchText.text)` triggers `updateFromText()` automatically +- Pros: clean separation. Cons: needs callback threading. + +**Option B: Pass `searchText` MutableState directly** +- SearchHistorySection writes to the state directly +- Pros: simple. Cons: tight coupling. + +## Acceptance Criteria + +- [ ] Clicking a recent search item populates the search input with that text +- [ ] Search results start loading immediately after populating +- [ ] The search bar stays expanded (doesn't collapse) diff --git a/todos/003-pending-p2-cmd-f-context-aware.md b/todos/003-pending-p2-cmd-f-context-aware.md new file mode 100644 index 0000000000..6e743ea101 --- /dev/null +++ b/todos/003-pending-p2-cmd-f-context-aware.md @@ -0,0 +1,35 @@ +--- +status: pending +priority: p2 +issue_id: "003" +tags: [code-review, ui, desktop, search, keyboard] +--- + +# Cmd+F should be context-aware: inline on feeds, full search elsewhere + +## Problem Statement + +Cmd+F currently toggles `feedSearchActiveState` which only works on the Home/Feeds screen. When on Messages, Settings, Bookmarks, or other non-feed screens, Cmd+F should open the full Search column instead. + +## Findings + +- Main.kt MenuBar: Cmd+F sets `feedSearchActiveState.value = !feedSearchActiveState.value` +- FeedScreen reads `LocalFeedSearchActive.current` — only works when FeedScreen is visible +- On other screens (Messages, Settings, etc.), the state changes but nothing happens visually + +## Proposed Solutions + +**Option A: Check current screen type in Cmd+F handler** +- Read `activeColumnType` from deck/single-pane state +- If HomeFeed/GlobalFeed/CustomFeed → toggle inline search +- Otherwise → navigate to Search column + +**Option B: Use two shortcuts** +- Cmd+F → always opens inline search on feed (no-op on other screens) +- Cmd+Shift+F → always opens full Search column + +## Acceptance Criteria + +- [ ] Cmd+F on Home/Feeds screen → inline search expands +- [ ] Cmd+F on any other screen → opens full Search column/screen +- [ ] Cmd+F on already-expanded search → collapses it diff --git a/todos/004-pending-p3-hardcoded-status-colors.md b/todos/004-pending-p3-hardcoded-status-colors.md new file mode 100644 index 0000000000..8e3cf3cdff --- /dev/null +++ b/todos/004-pending-p3-hardcoded-status-colors.md @@ -0,0 +1,36 @@ +--- +status: pending +priority: p3 +issue_id: "004" +tags: [code-review, theming, desktop] +--- + +# Hardcoded status colors should use theme tokens + +## Problem Statement + +32 inline Color() constructors across 10 desktop files use hardcoded RGB values for status indicators (green/red/amber). These don't adapt to dark/light mode properly. + +## Findings + +Key files: RelayStatusCard.kt, TorStatusIndicator.kt, LoginProgressSteps.kt, MediaServerSettings.kt, DevSettingsSection.kt, NewKeyWarningCard.kt, ProfileInfoCard.kt + +Common hardcoded values: +- `Color(0xFF4CAF50)` green — should use a semantic success color +- `Color(0xFFF44336)` red — should use `MaterialTheme.colorScheme.error` +- `Color(0xFFFFB300)` amber — should use a semantic warning color +- `Color.Red` / `Color.Green` — bare Material colors + +## Proposed Solutions + +Extract semantic status colors as ColorScheme extensions: +```kotlin +val ColorScheme.statusSuccess: Color get() = if (isLight) Color(0xFF339900) else Color(0xFF99cc33) +val ColorScheme.statusError: Color get() = error +val ColorScheme.statusWarning: Color get() = if (isLight) Color(0xFFC09B14) else Color(0xFFE1C419) +``` + +## Acceptance Criteria + +- [ ] No bare Color.Red/Green/Yellow in desktop UI files +- [ ] Status colors adapt properly to dark/light mode diff --git a/todos/005-pending-p3-inline-shape-audit.md b/todos/005-pending-p3-inline-shape-audit.md new file mode 100644 index 0000000000..7ebabde682 --- /dev/null +++ b/todos/005-pending-p3-inline-shape-audit.md @@ -0,0 +1,26 @@ +--- +status: pending +priority: p3 +issue_id: "005" +tags: [code-review, theming, desktop] +--- + +# ~40 inline RoundedCornerShape() calls should use MaterialTheme.shapes + +## Problem Statement + +The desktopApp module has ~40 inline `RoundedCornerShape()` calls with values 4dp, 8dp, 10dp, 12dp, 16dp. These should map to `MaterialTheme.shapes.*` tokens for consistency with the unified Amethyst shape system. + +## Findings + +Common clusters: +- `8.dp` (~20 occurrences) → `MaterialTheme.shapes.small` +- `12.dp` (~5 occurrences) → `MaterialTheme.shapes.medium` +- `16.dp` (~4 occurrences) → `MaterialTheme.shapes.large` +- `100.dp` / `999.dp` → pill shapes (acceptable as-is) + +## Acceptance Criteria + +- [ ] All 8dp corner shapes use MaterialTheme.shapes.small +- [ ] All 12dp corner shapes use MaterialTheme.shapes.medium +- [ ] All 16dp corner shapes use MaterialTheme.shapes.large