Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-amethyst-sdOWe

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
This commit is contained in:
Claude
2026-05-29 13:32:49 +00:00
182 changed files with 9075 additions and 1492 deletions
+73 -196
View File
@@ -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 <https://github.com/nostr-protocol/nips> — use
`/nip <number>` 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 <verb>` 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 <number>` - 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-<feature>` or `fix/desktop-<issue>`
- 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)
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
@@ -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),
@@ -553,6 +553,12 @@ class Account(
val liveMusicPlaylistsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultMusicPlaylistsFollowList)
val liveMusicPlaylistsFollowListsPerRelay = OutboxLoaderState(liveMusicPlaylistsFollowLists, cache, scope).flow
val livePodcastEpisodesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPodcastEpisodesFollowList)
val livePodcastEpisodesFollowListsPerRelay = OutboxLoaderState(livePodcastEpisodesFollowLists, cache, scope).flow
val livePodcastsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPodcastsFollowList)
val livePodcastsFollowListsPerRelay = OutboxLoaderState(livePodcastsFollowLists, cache, scope).flow
val liveSoftwareAppsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultSoftwareAppsFollowList)
val liveSoftwareAppsFollowListsPerRelay = OutboxLoaderState(liveSoftwareAppsFollowLists, cache, scope).flow
@@ -177,6 +177,8 @@ class AccountSettings(
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultMusicTracksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultMusicPlaylistsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPodcastEpisodesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPodcastsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultSoftwareAppsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = 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)
}
@@ -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)
}
@@ -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),
),
)
}
@@ -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,
@@ -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<NormalizedRelayUrl> {
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
}
@@ -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)
}
}
}
}
}
@@ -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,
@@ -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"
@@ -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<Boolean>,
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 ->
@@ -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<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
composableFromEnd<Route.MusicTracks> { MusicTracksScreen(accountViewModel, nav) }
composableFromEnd<Route.MusicPlaylists> { MusicPlaylistsScreen(accountViewModel, nav) }
composableFromEnd<Route.PodcastEpisodes> { PodcastEpisodesScreen(accountViewModel, nav) }
composableFromEnd<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
@@ -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<NavBarItem, NavBarItemDef> =
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> =
NavBarItem.SHORTS,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.PODCAST_EPISODES,
NavBarItem.PODCASTS,
NavBarItem.PUBLIC_CHATS,
NavBarItem.FOLLOW_PACKS,
NavBarItem.LIVE_STREAMS,
@@ -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,
@@ -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,
@@ -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"
}
@@ -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)
}
@@ -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"))
@@ -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<Pair<Locale, SimpleDateFormat>>()
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)
}
@@ -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(
@@ -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,
@@ -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(),
@@ -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(),
@@ -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))
}
@@ -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)
}
}
}
@@ -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<Float>+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<Color>,
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,
)
}
}
}
}
@@ -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<Color>,
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,
)
}
}
}
}
}
}
@@ -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)
@@ -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)
@@ -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 -> "—"
}
@@ -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 =
@@ -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<Pair<Locale, SimpleDateFormat>>()
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(
@@ -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() {
@@ -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())
@@ -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)
}
@@ -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()
}
}
@@ -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
) &&
@@ -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)
@@ -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,
)
@@ -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)
@@ -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,
)
@@ -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 ||
@@ -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
@@ -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,
@@ -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)
@@ -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(),
)
}
}
@@ -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())
}
}
@@ -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)
@@ -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(),
)
}
}
@@ -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())
}
}
@@ -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<Note>() {
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<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
accept(it, params)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.livePodcastEpisodesFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -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<Note>() {
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<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it ->
accept(it, params)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.livePodcastsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -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<PodcastEpisodesQueryState>() {
private val episodes = PodcastEpisodesSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = episodes.invalidateFilters()
override fun destroy() = episodes.destroy()
}
@@ -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)
}
@@ -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<PodcastEpisodesQueryState>,
) : PerUserAndFollowListEoseManager<PodcastEpisodesQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: PodcastEpisodesQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<User, List<Job>>()
@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() }
}
}
@@ -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<PodcastsQueryState>() {
private val podcasts = PodcastsSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = podcasts.invalidateFilters()
override fun destroy() = podcasts.destroy()
}
@@ -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)
}
@@ -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<PodcastsQueryState>,
) : PerUserAndFollowListEoseManager<PodcastsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: PodcastsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<User, List<Job>>()
@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() }
}
}
@@ -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<RelayBasedFilter> = makePodcastFilter(feedSettings, PODCAST_EPISODE_KINDS, since, defaultSince)
fun makePodcastsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince)
private fun makePodcastFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> =
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()
}
@@ -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<Int>,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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()
}
@@ -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<Int>,
communities: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
community: String,
authors: Set<String>?,
since: Long? = null,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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,
)
}
}
@@ -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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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()
}
}
@@ -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<Int>,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
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<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
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()
}
@@ -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<Int>,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = kinds,
tags = mapOf("t" to hashtags.sorted()),
limit = 200,
since = since,
),
),
)
fun filterPodcastEventsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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()
}
@@ -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<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
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,
),
)
}
}
@@ -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
@@ -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 ||
@@ -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,
)
@@ -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
@@ -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
// -------------------------------------------------------------------------
@@ -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() {
@@ -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))
}
@@ -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
@@ -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,
@@ -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()
@@ -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() }
@@ -745,6 +745,36 @@
<string name="nests_servers_empty">Zatím žádné servery. Přidejte server níže nebo vyberte z doporučených.</string>
<string name="longs">Videa</string>
<string name="articles">Články</string>
<string name="route_music_tracks">Hudba</string>
<string name="route_music_playlists">Playlisty</string>
<string name="new_music_track">Nová hudební stopa</string>
<string name="new_music_playlist">Nový playlist</string>
<string name="music_track_title_label">Název</string>
<string name="music_track_title_placeholder">Název stopy</string>
<string name="music_track_artist_label">Interpret</string>
<string name="music_track_artist_placeholder">Jméno interpreta</string>
<string name="music_track_audio_url_label">URL zvuku</string>
<string name="music_track_audio_url_placeholder">https://… .mp3</string>
<string name="music_track_image_url_label">URL obrázku obalu (volitelné)</string>
<string name="music_track_album_label">Album (volitelné)</string>
<string name="music_track_duration_label">Délka v sekundách (volitelné)</string>
<string name="music_track_description_label">Text nebo kredity (volitelné)</string>
<string name="music_track_delete">Smazat tuto stopu</string>
<string name="music_track_delete_confirm">Publikovat NIP-09 smazání této hudební stopy?</string>
<string name="add_to_music_playlist">Přidat do playlistu</string>
<string name="add_to_music_playlist_title">Přidat do playlistu</string>
<string name="add_to_music_playlist_empty">Zatím nemáte žádné playlisty. Pomocí pole výše si nějaký vytvořte.</string>
<string name="add_to_music_playlist_manage_all">Spravovat všechny playlisty</string>
<string name="music_playlist_new_dialog_title">Nový playlist</string>
<string name="music_playlist_new_title_placeholder">Název playlistu</string>
<string name="music_playlist_create_action">Vytvořit</string>
<string name="music_playlist_untitled">(playlist bez názvu)</string>
<plurals name="music_playlist_track_count_short">
<item quantity="one">%1$d stopa</item>
<item quantity="few">%1$d stopy</item>
<item quantity="many">%1$d stopy</item>
<item quantity="other">%1$d stop</item>
</plurals>
<string name="private_bookmarks">Soukromé záložky</string>
<string name="public_bookmarks">Veřejné záložky</string>
<string name="add_to_private_bookmarks">Přidat do soukromých záložek</string>
@@ -912,6 +942,7 @@
<string name="payment_target_authority">Adresa</string>
<string name="delete_payment_target">Smazat platební cíl</string>
<string name="no_payment_app_found">Nebyla nalezena žádná aplikace pro zpracování této platby. Nainstalujte prosím kompatibilní peněženku.</string>
<string name="no_payment_app_found_for_type">Není nainstalována žádná aplikace pro platby typu %1$s. Nainstalujte prosím kompatibilní peněženku.</string>
<string name="error_dialog_payment_error">Nelze otevřít platbu</string>
<string name="uploading_state_ready">Nezačaté</string>
<string name="uploading_state_compressing">Kompresuje</string>
@@ -1381,7 +1412,30 @@
<string name="external_url_scope">Komentář k webové stránce</string>
<string name="external_id_scope">Komentář k externímu zdroji</string>
<string name="long_form_reading_minutes">%1$d min čtení</string>
<plurals name="music_playlist_track_count">
<item quantity="one">%1$d stopa</item>
<item quantity="few">%1$d stopy</item>
<item quantity="many">%1$d stopy</item>
<item quantity="other">%1$d stop</item>
</plurals>
<plurals name="music_playlist_more_tracks">
<item quantity="one">+%1$d další stopa</item>
<item quantity="few">+%1$d další stopy</item>
<item quantity="many">+%1$d další stopy</item>
<item quantity="other">+%1$d dalších stop</item>
</plurals>
<string name="music_playlist_collaborative">Sdílený</string>
<string name="music_playlist_private">Soukromý</string>
<string name="music_playlist_unknown_track">Neznámá stopa</string>
<string name="music_playlist_loading_track">Načítání stopy…</string>
<string name="music_track_cover_upload_cta">Nahrát obrázek obalu</string>
<string name="music_track_cover_upload_hint">Vyberte čtvercový obrázek, který bude obalem této stopy.</string>
<string name="music_track_audio_upload_cta">Nahrát zvukový soubor</string>
<string name="music_track_audio_upload_hint">Vyberte MP3, WAV nebo FLAC. Při uložení bude nahrán na váš mediální server.</string>
<string name="music_track_audio_picked">Zvukový soubor připraven k nahrání</string>
<string name="music_track_uploading">Nahrávání…</string>
<string name="music_track_uploading_banner">Nahrávání obalu a zvuku a publikování. Bude pokračovat, i když opustíte obrazovku.</string>
<string name="music_track_upload_failed">Nahrávání selhalo: %1$s</string>
<string name="loading_location">Načítání umístění</string>
<string name="lack_location_permissions">Žádná lokace oprávnění</string>
<string name="add_sensitive_content_explainer">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í</string>
@@ -1476,6 +1530,9 @@
<string name="wallet_number">Peněženka %1$s</string>
<string name="error_opening_external_signer">Chyba při otevírání aplikace pro podpis</string>
<string name="error_opening_external_signer_description">Signner aplikace nebyla nalezena. Zkontrolujte, zda aplikace nebyla odinstalována</string>
<string name="error_video_playback_failed">Toto video nelze přehrát</string>
<string name="error_video_playback_failed_description">Tento kodek nebo formát není na tomto zařízení podporován (%1$s). Zkuste jej otevřít v prohlížeči.</string>
<string name="error_video_open_in_browser">Otevřít v prohlížeči</string>
<string name="sign_request_rejected2">Požadavek na podpis byl zamítnut</string>
<string name="sign_request_rejected">Žádost o podpis byla zamítnuta</string>
<string name="sign_request_rejected_description">Ujistěte se, že podepisující aplikace autorizovala tuto transakci</string>
@@ -1624,6 +1681,7 @@
<string name="metadata_strip_failed_upload">Přesto nahrát</string>
<string name="metadata_strip_failed_upload_cancelled">Nepodařilo se odstranit soukromá metadata z média. Nahrávání zrušeno.</string>
<string name="upload_cancelled">Nahrávání zrušeno</string>
<string name="avif_metadata_strip_failed">Nelze odstranit metadata z AVIF: %1$s</string>
<string name="edit_draft">Upravit koncept</string>
<string name="login_with_qr_code">Přihlášení pomocí QR kódu</string>
<string name="route">Trasa</string>
@@ -1686,6 +1744,7 @@
<string name="wallet_invalid_uri">Neplatné URI NWC připojení</string>
<string name="wallet_move_up">Posunout nahoru</string>
<string name="wallet_move_down">Posunout dolů</string>
<string name="wallet_reorder">Změnit pořadí peněženek</string>
<string name="wallet_onchain_transactions">On-chain transakce</string>
<string name="wallet_onchain_no_address">Pro tento účet není k dispozici žádná on-chain adresa.</string>
<string name="wallet_onchain_no_backend">Není nakonfigurován žádný on-chain backend.</string>
@@ -2284,6 +2343,8 @@
<string name="kind_user_settings">Uživatelská nastavení</string>
<string name="kind_audio_header">Audio hlavička</string>
<string name="kind_audio_track">Audio stopa</string>
<string name="kind_music_track">Hudební stopa</string>
<string name="kind_music_playlist">Hudební playlist</string>
<string name="kind_badge_awards">Ocenění odznaků</string>
<string name="kind_badge_definitions">Definice odznaků</string>
<string name="kind_accepted_badge_set">Přijatá sada odznaků</string>
@@ -569,6 +569,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="nip82_section_about">Über</string>
<string name="nip82_section_platforms">Plattformen</string>
<string name="nip82_section_topics">Themen</string>
<string name="nip82_section_links">Verknüpfungen</string>
<string name="nip82_section_latest_release">Neueste Version</string>
<string name="nip82_section_comments">Kommentare</string>
<string name="nip82_no_comments">Sei der Erste, der kommentiert.</string>
@@ -734,6 +735,34 @@ anz der Bedingungen ist erforderlich</string>
<string name="nests_servers_empty">Noch keine Nest-Server. Füge unten einen hinzu oder wähle einen empfohlenen Server.</string>
<string name="longs">Videos</string>
<string name="articles">Artikel</string>
<string name="route_music_tracks">Musik</string>
<string name="route_music_playlists">Wiedergabelisten</string>
<string name="new_music_track">Neuer Musiktitel</string>
<string name="new_music_playlist">Neue Playlist</string>
<string name="music_track_title_label">Titel</string>
<string name="music_track_title_placeholder">Titelname</string>
<string name="music_track_artist_label">Künstler</string>
<string name="music_track_artist_placeholder">Name des Künstlers</string>
<string name="music_track_audio_url_label">Audio-URL</string>
<string name="music_track_audio_url_placeholder">https://… .mp3</string>
<string name="music_track_image_url_label">URL des Coverbilds (optional)</string>
<string name="music_track_album_label">Album (optional)</string>
<string name="music_track_duration_label">Dauer in Sekunden (optional)</string>
<string name="music_track_description_label">Songtext oder Credits (optional)</string>
<string name="music_track_delete">Diesen Titel löschen</string>
<string name="music_track_delete_confirm">NIP-09-Löschung für diesen Musiktitel veröffentlichen?</string>
<string name="add_to_music_playlist">Zur Playlist hinzufügen</string>
<string name="add_to_music_playlist_title">Zur Playlist hinzufügen</string>
<string name="add_to_music_playlist_empty">Du hast noch keine eigenen Playlists. Verwende das Feld oben, um eine zu erstellen.</string>
<string name="add_to_music_playlist_manage_all">Alle Playlists verwalten</string>
<string name="music_playlist_new_dialog_title">Neue Playlist</string>
<string name="music_playlist_new_title_placeholder">Playlist-Name</string>
<string name="music_playlist_create_action">Erstellen</string>
<string name="music_playlist_untitled">(Playlist ohne Titel)</string>
<plurals name="music_playlist_track_count_short">
<item quantity="one">%1$d Titel</item>
<item quantity="other">%1$d Titel</item>
</plurals>
<string name="private_bookmarks">Private Lesezeichen</string>
<string name="public_bookmarks">Öffentliche Lesezeichen</string>
<string name="add_to_private_bookmarks">Zu den privaten Lesezeichen hinzufügen</string>
@@ -901,6 +930,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="payment_target_authority">Adresse</string>
<string name="delete_payment_target">Zahlungsziel löschen</string>
<string name="no_payment_app_found">Keine App gefunden, die diese Zahlung verarbeiten kann. Bitte installieren Sie eine kompatible Wallet.</string>
<string name="no_payment_app_found_for_type">Es ist keine App installiert, die Zahlungen vom Typ %1$s verarbeiten kann. Bitte installiere eine kompatible Wallet.</string>
<string name="error_dialog_payment_error">Zahlung konnte nicht geöffnet werden</string>
<string name="uploading_state_ready">Noch nicht angefangen</string>
<string name="uploading_state_compressing">Komprimieren</string>
@@ -1369,7 +1399,26 @@ anz der Bedingungen ist erforderlich</string>
<string name="external_url_scope">Eine Webseite kommentieren</string>
<string name="external_id_scope">Eine externe Ressource kommentieren</string>
<string name="long_form_reading_minutes">%1$d Min. Lesezeit</string>
<plurals name="music_playlist_track_count">
<item quantity="one">%1$d Titel</item>
<item quantity="other">%1$d Titel</item>
</plurals>
<plurals name="music_playlist_more_tracks">
<item quantity="one">+%1$d weiterer Titel</item>
<item quantity="other">+%1$d weitere Titel</item>
</plurals>
<string name="music_playlist_collaborative">Kollaborativ</string>
<string name="music_playlist_private">Privat</string>
<string name="music_playlist_unknown_track">Unbekannter Titel</string>
<string name="music_playlist_loading_track">Titel wird geladen…</string>
<string name="music_track_cover_upload_cta">Coverbild hochladen</string>
<string name="music_track_cover_upload_hint">Wähle ein quadratisches Bild als Artwork für diesen Titel.</string>
<string name="music_track_audio_upload_cta">Audiodatei hochladen</string>
<string name="music_track_audio_upload_hint">Wähle eine MP3, WAV oder FLAC. Sie wird beim Speichern auf deinen Medienserver hochgeladen.</string>
<string name="music_track_audio_picked">Audiodatei bereit zum Hochladen</string>
<string name="music_track_uploading">Wird hochgeladen…</string>
<string name="music_track_uploading_banner">Cover und Audio werden hochgeladen und veröffentlicht. Der Vorgang läuft weiter, auch wenn du den Bildschirm verlässt.</string>
<string name="music_track_upload_failed">Hochladen fehlgeschlagen: %1$s</string>
<string name="loading_location">Standort wird geladen</string>
<string name="lack_location_permissions">Keine Standortberechtigungen</string>
<string name="add_sensitive_content_explainer">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</string>
@@ -1464,6 +1513,9 @@ anz der Bedingungen ist erforderlich</string>
<string name="wallet_number">Brieftasche %1$s</string>
<string name="error_opening_external_signer">Fehler beim Öffnen der Signatur-App</string>
<string name="error_opening_external_signer_description">Die Unterzeichner-App konnte nicht gefunden werden. Prüfen Sie, ob die App nicht deinstalliert wurde</string>
<string name="error_video_playback_failed">Dieses Video kann nicht abgespielt werden</string>
<string name="error_video_playback_failed_description">Der Codec oder das Format wird auf diesem Gerät nicht unterstützt (%1$s). Versuche, es im Browser zu öffnen.</string>
<string name="error_video_open_in_browser">Im Browser öffnen</string>
<string name="sign_request_rejected2">Signaturanfrage abgelehnt</string>
<string name="sign_request_rejected">Signaturanfrage abgelehnt</string>
<string name="sign_request_rejected_description">Stellen Sie sicher, dass die Unterzeichner-Anwendung diese Transaktion autorisiert hat</string>
@@ -1612,6 +1664,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="metadata_strip_failed_upload">Trotzdem hochladen</string>
<string name="metadata_strip_failed_upload_cancelled">Private Metadaten konnten nicht aus der Mediendatei entfernt werden. Hochladen abgebrochen.</string>
<string name="upload_cancelled">Hochladen abgebrochen</string>
<string name="avif_metadata_strip_failed">Metadaten können nicht aus AVIF entfernt werden: %1$s</string>
<string name="edit_draft">Entwurf bearbeiten</string>
<string name="login_with_qr_code">Einloggen mit QR-Code</string>
<string name="route">Route</string>
@@ -1674,6 +1727,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="wallet_invalid_uri">Ungültige NWC-Verbindungs-URI</string>
<string name="wallet_move_up">Nach oben</string>
<string name="wallet_move_down">Nach unten</string>
<string name="wallet_reorder">Wallets neu anordnen</string>
<string name="wallet_onchain_transactions">On-Chain-Transaktionen</string>
<string name="wallet_onchain_no_address">Keine On-Chain-Adresse für dieses Konto verfügbar.</string>
<string name="wallet_onchain_no_backend">Kein On-Chain-Backend konfiguriert.</string>
@@ -2264,6 +2318,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="kind_user_settings">Benutzereinstellungen</string>
<string name="kind_audio_header">Audio-Header</string>
<string name="kind_audio_track">Audiospur</string>
<string name="kind_music_track">Musiktitel</string>
<string name="kind_music_playlist">Musik-Playlist</string>
<string name="kind_badge_awards">Abzeichen-Auszeichnungen</string>
<string name="kind_badge_definitions">Abzeichen-Definitionen</string>
<string name="kind_accepted_badge_set">Akzeptiertes Abzeichen-Set</string>
@@ -131,6 +131,7 @@
<string name="write_a_message">Üzenet írása…</string>
<string name="post">Közzététel</string>
<string name="save">Mentés</string>
<string name="send">Küldés</string>
<string name="create">Létrehozás</string>
<string name="rename">Átnevezés</string>
<string name="cancel">Mégse</string>
@@ -561,6 +562,15 @@
<string name="nip82_repository_label">Forrás: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Letöltés</string>
<string name="nip82_section_about">Névjegy</string>
<string name="nip82_section_platforms">Platformok</string>
<string name="nip82_section_topics">Témák</string>
<string name="nip82_section_links">Hivatkozások</string>
<string name="nip82_section_latest_release">Legújabb kiadás</string>
<string name="nip82_section_comments">Megjegyzések</string>
<string name="nip82_no_comments">Legyen Ön az első hozzászóló.</string>
<string name="nip82_older_releases_show">Korábbi kiadások megjelenítése</string>
<string name="nip82_older_releases_hide">Korábbi kiadások elrejtése</string>
<string name="calendars">Naptárak</string>
<string name="shorts">Rövidek</string>
<string name="public_chats">Nyilvános csevegések</string>
@@ -721,6 +731,36 @@
<string name="nests_servers_empty">Még nincsenek hangszoba-kiszolgálók. Adjon hozzá egyet alább, vagy válasszon egy ajánlott kiszolgálót.</string>
<string name="longs">Videók</string>
<string name="articles">Cikkek</string>
<string name="route_music_tracks">Zene</string>
<string name="route_music_playlists">Lejátszási listák</string>
<string name="route_podcast_episodes">Részek</string>
<string name="route_podcasts">Hangműsor</string>
<string name="new_music_track">Új zeneszám</string>
<string name="new_music_playlist">Új lejátszási lista</string>
<string name="music_track_title_label">Cím</string>
<string name="music_track_title_placeholder">Szám címe</string>
<string name="music_track_artist_label">Előadó</string>
<string name="music_track_artist_placeholder">Előadó neve</string>
<string name="music_track_audio_url_label">Hang webcíme</string>
<string name="music_track_audio_url_placeholder">https://… .mp3</string>
<string name="music_track_image_url_label">Borítókép webcíme (nem kötelező)</string>
<string name="music_track_album_label">Album (nem kötelező)</string>
<string name="music_track_duration_label">Időtartam másodpercben (nem kötelező)</string>
<string name="music_track_description_label">Dalszöveg vagy stáblista (nem kötelező)</string>
<string name="music_track_delete">Szám törlése</string>
<string name="music_track_delete_confirm">Közzétesz egy NIP-09 törlést ehhez a zeneszámhoz?</string>
<string name="add_to_music_playlist">Hozzáadás a lejátszási listához</string>
<string name="add_to_music_playlist_title">Hozzáadás a lejátszási listához</string>
<string name="add_to_music_playlist_empty">Önnek még nincs lejátszási listája. A fenti mező segítségével hozhat létre egyet.</string>
<string name="add_to_music_playlist_manage_all">Összes lejátszási lista kezelése</string>
<string name="music_playlist_new_dialog_title">Új lejátszási lista</string>
<string name="music_playlist_new_title_placeholder">Lejátszási lista neve</string>
<string name="music_playlist_create_action">Létrehozás</string>
<string name="music_playlist_untitled">(cím nélküli lejátszási lista)</string>
<plurals name="music_playlist_track_count_short">
<item quantity="one">%1$d szám</item>
<item quantity="other">%1$d szám</item>
</plurals>
<string name="private_bookmarks">Privát könyvjelzők</string>
<string name="public_bookmarks">Nyilvános könyvjelzők</string>
<string name="add_to_private_bookmarks">Hozzáadás a privát könyvjelzőkhöz</string>
@@ -1360,6 +1400,26 @@
<string name="external_url_scope">Megjegyzés egy weboldalhoz</string>
<string name="external_id_scope">Megjegyzés egy külső forráshoz</string>
<string name="long_form_reading_minutes">Olvasási idő: %1$d perc</string>
<plurals name="music_playlist_track_count">
<item quantity="one">%1$d szám</item>
<item quantity="other">%1$d szám</item>
</plurals>
<plurals name="music_playlist_more_tracks">
<item quantity="one">+%1$d szám</item>
<item quantity="other">+%1$d szám</item>
</plurals>
<string name="music_playlist_collaborative">Közreműködési</string>
<string name="music_playlist_private">Privát</string>
<string name="music_playlist_unknown_track">Ismeretlen szám</string>
<string name="music_playlist_loading_track">Szám betöltése…</string>
<string name="music_track_cover_upload_cta">Egy borítókép feltöltése</string>
<string name="music_track_cover_upload_hint">Válasszon ki egy négyzet alakú képet, amely a szám borítójaként fog szolgálni.</string>
<string name="music_track_audio_upload_cta">Egy hangfájl feltöltése</string>
<string name="music_track_audio_upload_hint">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.</string>
<string name="music_track_audio_picked">A hangfájl készen áll a feltöltésre</string>
<string name="music_track_uploading">Feltöltés…</string>
<string name="music_track_uploading_banner">Borító és hangfájl feltöltése, majd közzététel. Ez akkor is folytatódik, ha elhagyja a képernyőt.</string>
<string name="music_track_upload_failed">Nem sikerült feltölteni: %1$s</string>
<string name="loading_location">Helyszín betöltése…</string>
<string name="lack_location_permissions">A helyszín-meghatározás nincs engedélyezve</string>
<string name="add_sensitive_content_explainer">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</string>
@@ -2260,6 +2320,12 @@
<string name="kind_user_settings">Felhasználói beállítások</string>
<string name="kind_audio_header">Audio-fejléc</string>
<string name="kind_audio_track">Hangsáv</string>
<string name="kind_music_track">Zeneszám</string>
<string name="kind_music_playlist">Zenei lejátszási lista</string>
<string name="kind_podcast_episode">Hangműsor-rész</string>
<string name="kind_podcast_metadata">Hangműsor</string>
<string name="kind_authored_podcasts">Saját készítésű hangműsorok</string>
<string name="kind_favorite_podcasts">Kedvenc hangműsorok</string>
<string name="kind_badge_awards">Kitűződíjak</string>
<string name="kind_badge_definitions">Kitűzők meghatározása</string>
<string name="kind_accepted_badge_set">Elfogadott kitűzőlista</string>
@@ -14,6 +14,12 @@
<item quantity="many">Ten post ma więcej niż %1$d hashtagów</item>
<item quantity="other">Ten post zawiera więcej niż %1$d hashtagów</item>
</plurals>
<plurals name="nip82_assets_count">
<item quantity="one">%1$d zestaw aktywów</item>
<item quantity="few">%1$d zestawów aktywów</item>
<item quantity="many">%1$d zestawów aktywów</item>
<item quantity="other">%1$d zestawy aktywów</item>
</plurals>
<string name="post_not_found">Wydarzenie jest wczytywane lub nie można go znaleźć na liście transmiterów</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Zdjęcie kanału</string>
@@ -129,6 +135,7 @@
<string name="write_a_message">Napisz wiadomość…</string>
<string name="post">Wyślij</string>
<string name="save">Zapisz</string>
<string name="send">Wyślij</string>
<string name="create">Utwórz</string>
<string name="rename">Zmień nazwę</string>
<string name="cancel">Anuluj</string>
@@ -567,6 +574,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="nip82_repository_label">Źródło: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Pobierz</string>
<string name="nip82_section_about">O NIP-ie</string>
<string name="nip82_section_platforms">Platformy</string>
<string name="nip82_section_topics">Tematy</string>
<string name="nip82_section_links">Linki</string>
<string name="nip82_section_latest_release">Najnowsze wydanie</string>
<string name="nip82_section_comments">Komentarze</string>
<string name="nip82_no_comments">Bądź pierwszym, który skomentuje.</string>
<string name="nip82_older_releases_show">Pokaż starsze wersje</string>
<string name="nip82_older_releases_hide">Ukryj starsze wersje</string>
<string name="calendars">Kalendarze</string>
<string name="shorts">Filmiki</string>
<string name="public_chats">Czaty publiczne</string>
@@ -729,6 +745,30 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="nests_servers_empty">Brak serwerów Nest. Dodaj jeden poniżej lub wybierz polecany serwer.</string>
<string name="longs">Filmy wideo</string>
<string name="articles">Artykuły</string>
<string name="route_music_tracks">Muzyka</string>
<string name="route_music_playlists">Playlisty</string>
<string name="new_music_track">Nowy utwór</string>
<string name="new_music_playlist">Nowa playlista</string>
<string name="music_track_title_label">Tytuł</string>
<string name="music_track_title_placeholder">Tytuł utworu</string>
<string name="music_track_artist_label">Artysta</string>
<string name="music_track_artist_placeholder">Nazwa wykonawcy</string>
<string name="music_track_audio_url_label">Odnośnik audio</string>
<string name="music_track_audio_url_placeholder">https://… .mp3</string>
<string name="music_track_image_url_label">Adres miniaturki (opcjonalnie)</string>
<string name="music_track_album_label">Album (opcjonalnie)</string>
<string name="music_track_duration_label">Czas trwania w sekundach (opcjonalnie)</string>
<string name="music_track_description_label">Teksty lub wkład twórczy (opcjonalne)</string>
<string name="music_track_delete">Usuń ten utwór</string>
<string name="music_track_delete_confirm">Opublikować NIP-09 informację o usunięciu tego utworu?</string>
<string name="add_to_music_playlist">Dodaj do playlisty</string>
<string name="add_to_music_playlist_title">Dodaj do playlisty</string>
<string name="add_to_music_playlist_empty">Nie masz jeszcze żadnych list odtwarzania. Skorzystaj z pola powyżej, aby utworzyć listę.</string>
<string name="add_to_music_playlist_manage_all">Zarządzaj wszystkimi playlistami</string>
<string name="music_playlist_new_dialog_title">Nowa playlista</string>
<string name="music_playlist_new_title_placeholder">Nazwa playlisty</string>
<string name="music_playlist_create_action">Utwórz</string>
<string name="music_playlist_untitled">(niezatytułowana playlista)</string>
<string name="private_bookmarks">Prywatne Zakładki</string>
<string name="public_bookmarks">Publiczne zakładki</string>
<string name="add_to_private_bookmarks">Dodaj do prywatnych zakładek</string>
@@ -1368,6 +1408,18 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="external_url_scope">Skomentuj na stronie internetowej</string>
<string name="external_id_scope">Skomentuj na zewnętrznym serwisie</string>
<string name="long_form_reading_minutes">Czas czytania: %1$d min.</string>
<string name="music_playlist_collaborative">Współtworzona</string>
<string name="music_playlist_private">Prywatna</string>
<string name="music_playlist_unknown_track">Nieznany utwór</string>
<string name="music_playlist_loading_track">Ładowanie utworu…</string>
<string name="music_track_cover_upload_cta">Wgraj miniaturkę</string>
<string name="music_track_cover_upload_hint">Wybierz kwadratowe zdjęcie, które posłuży jako okładka tego utworu.</string>
<string name="music_track_audio_upload_cta">Wgraj plik audio</string>
<string name="music_track_audio_upload_hint">Wybierz plik w formacie MP3, WAV lub FLAC. Po kliknięciu „Zapisz” zostanie on przesłany na Twój serwer multimediów.</string>
<string name="music_track_audio_picked">Plik audio gotowy do przesłania</string>
<string name="music_track_uploading">Wgrywanie…</string>
<string name="music_track_uploading_banner">Wgraj miniaturę i plik audio, a następnie opublikuj. Proces ten będzie kontynuowany nawet po opuszczeniu ekranu.</string>
<string name="music_track_upload_failed">Błąd wgrywania: %1$s</string>
<string name="loading_location">Pobieranie lokalizacji</string>
<string name="lack_location_permissions">Brak dostępu do lokalizacji</string>
<string name="add_sensitive_content_explainer">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</string>
@@ -2264,6 +2316,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="kind_user_settings">Ustawienia użytkownika</string>
<string name="kind_audio_header">Nagłówek audio</string>
<string name="kind_audio_track">Ścieżka audio</string>
<string name="kind_music_track">Utwór muzyczny</string>
<string name="kind_music_playlist">Playlista muzyczna</string>
<string name="kind_badge_awards">Odznaki</string>
<string name="kind_badge_definitions">Definicje odznak</string>
<string name="kind_accepted_badge_set">Zestaw odznak „Przyjęty”</string>
@@ -729,6 +729,34 @@
<string name="nests_servers_empty">Inga nest-servrar än. Lägg till en nedan eller välj en rekommenderad server.</string>
<string name="longs">Videor</string>
<string name="articles">Artiklar</string>
<string name="route_music_tracks">Musik</string>
<string name="route_music_playlists">Spellistor</string>
<string name="new_music_track">Nytt musikspår</string>
<string name="new_music_playlist">Ny spellista</string>
<string name="music_track_title_label">Titel</string>
<string name="music_track_title_placeholder">Spårtitel</string>
<string name="music_track_artist_label">Artist</string>
<string name="music_track_artist_placeholder">Artistens namn</string>
<string name="music_track_audio_url_label">Ljud-URL</string>
<string name="music_track_audio_url_placeholder">https://… .mp3</string>
<string name="music_track_image_url_label">URL till omslagsbild (valfritt)</string>
<string name="music_track_album_label">Album (valfritt)</string>
<string name="music_track_duration_label">Längd i sekunder (valfritt)</string>
<string name="music_track_description_label">Sångtext eller medverkande (valfritt)</string>
<string name="music_track_delete">Ta bort det här spåret</string>
<string name="music_track_delete_confirm">Publicera en NIP-09-borttagning för det här musikspåret?</string>
<string name="add_to_music_playlist">Lägg till i spellista</string>
<string name="add_to_music_playlist_title">Lägg till i spellista</string>
<string name="add_to_music_playlist_empty">Du äger inga spellistor än. Använd fältet ovan för att skapa en.</string>
<string name="add_to_music_playlist_manage_all">Hantera alla spellistor</string>
<string name="music_playlist_new_dialog_title">Ny spellista</string>
<string name="music_playlist_new_title_placeholder">Spellistans namn</string>
<string name="music_playlist_create_action">Skapa</string>
<string name="music_playlist_untitled">(namnlös spellista)</string>
<plurals name="music_playlist_track_count_short">
<item quantity="one">%1$d spår</item>
<item quantity="other">%1$d spår</item>
</plurals>
<string name="private_bookmarks">Privata Bokmärken</string>
<string name="public_bookmarks">Publika Bokmärken</string>
<string name="add_to_private_bookmarks">Lägg till i Privata Bokmärken</string>
@@ -896,6 +924,7 @@
<string name="payment_target_authority">Adress</string>
<string name="delete_payment_target">Ta bort betalningsmottagare</string>
<string name="no_payment_app_found">Ingen app hittades för att hantera denna betalning. Installera en kompatibel plånbok.</string>
<string name="no_payment_app_found_for_type">Ingen app installerad för att hantera %1$s-betalningar. Installera en kompatibel plånbok.</string>
<string name="error_dialog_payment_error">Det gick inte att öppna betalningen</string>
<string name="uploading_state_ready">Ej påbörjad</string>
<string name="uploading_state_compressing">Komprimerar</string>
@@ -1364,7 +1393,26 @@
<string name="external_url_scope">Kommentera en webbplats</string>
<string name="external_id_scope">Kommentera en extern resurs</string>
<string name="long_form_reading_minutes">%1$d min läsning</string>
<plurals name="music_playlist_track_count">
<item quantity="one">%1$d spår</item>
<item quantity="other">%1$d spår</item>
</plurals>
<plurals name="music_playlist_more_tracks">
<item quantity="one">+%1$d spår till</item>
<item quantity="other">+%1$d spår till</item>
</plurals>
<string name="music_playlist_collaborative">Delad</string>
<string name="music_playlist_private">Privat</string>
<string name="music_playlist_unknown_track">Okänt spår</string>
<string name="music_playlist_loading_track">Läser in spår…</string>
<string name="music_track_cover_upload_cta">Ladda upp en omslagsbild</string>
<string name="music_track_cover_upload_hint">Välj en kvadratisk bild som omslag för det här spåret.</string>
<string name="music_track_audio_upload_cta">Ladda upp en ljudfil</string>
<string name="music_track_audio_upload_hint">Välj en MP3, WAV eller FLAC. Den laddas upp till din mediaserver när du sparar.</string>
<string name="music_track_audio_picked">Ljudfil redo att laddas upp</string>
<string name="music_track_uploading">Laddar upp…</string>
<string name="music_track_uploading_banner">Laddar upp omslag och ljud och publicerar. Detta fortsätter även om du lämnar skärmen.</string>
<string name="music_track_upload_failed">Uppladdning misslyckades: %1$s</string>
<string name="loading_location">Laddar position</string>
<string name="lack_location_permissions">Inga platsbehörigheter</string>
<string name="add_sensitive_content_explainer">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</string>
@@ -1459,6 +1507,9 @@
<string name="wallet_number">Plånbok %1$s</string>
<string name="error_opening_external_signer">Fel vid öppning av signeringsapp</string>
<string name="error_opening_external_signer_description">Signeringsprogrammet kunde inte hittas. Kontrollera att appen inte har avinstallerats</string>
<string name="error_video_playback_failed">Kan inte spela upp den här videon</string>
<string name="error_video_playback_failed_description">Codec eller format stöds inte på den här enheten (%1$s). Försök öppna den i din webbläsare.</string>
<string name="error_video_open_in_browser">Öppna i webbläsare</string>
<string name="sign_request_rejected2">Signeringsförfrågan avvisad</string>
<string name="sign_request_rejected">Underteckningsbegäran avvisad</string>
<string name="sign_request_rejected_description">Kontrollera att signeringsprogrammet har godkänt denna transaktion</string>
@@ -1607,6 +1658,7 @@
<string name="metadata_strip_failed_upload">Ladda upp ändå</string>
<string name="metadata_strip_failed_upload_cancelled">Kunde inte ta bort privat metadata från media. Uppladdning avbruten.</string>
<string name="upload_cancelled">Uppladdning avbruten</string>
<string name="avif_metadata_strip_failed">Kan inte ta bort metadata från AVIF: %1$s</string>
<string name="edit_draft">Redigera utkast</string>
<string name="login_with_qr_code">Logga in med QR-kod</string>
<string name="route">Rutt</string>
@@ -1669,6 +1721,7 @@
<string name="wallet_invalid_uri">Ogiltig URI för NWC-anslutning</string>
<string name="wallet_move_up">Flytta upp</string>
<string name="wallet_move_down">Flytta ner</string>
<string name="wallet_reorder">Ändra ordning på plånböcker</string>
<string name="wallet_onchain_transactions">On-chain-transaktioner</string>
<string name="wallet_onchain_no_address">Ingen on-chain-adress tillgänglig för detta konto.</string>
<string name="wallet_onchain_no_backend">Ingen on-chain-backend är konfigurerad.</string>
@@ -2259,6 +2312,8 @@
<string name="kind_user_settings">Användarinställningar</string>
<string name="kind_audio_header">Ljudhuvud</string>
<string name="kind_audio_track">Ljudspår</string>
<string name="kind_music_track">Musikspår</string>
<string name="kind_music_playlist">Musikspellista</string>
<string name="kind_badge_awards">Utmärkelser</string>
<string name="kind_badge_definitions">Utmärkelsedefinitioner</string>
<string name="kind_accepted_badge_set">Accepterad emblemuppsättning</string>
@@ -725,6 +725,8 @@
<string name="articles">文章</string>
<string name="route_music_tracks">音乐</string>
<string name="route_music_playlists">播放列表</string>
<string name="route_podcast_episodes">节目</string>
<string name="route_podcasts">播客</string>
<string name="new_music_track">新音乐曲目</string>
<string name="new_music_playlist">新建播放列表</string>
<string name="music_track_title_label">标题</string>
@@ -2305,6 +2307,10 @@
<string name="kind_audio_track">音轨</string>
<string name="kind_music_track">音乐曲目</string>
<string name="kind_music_playlist">音乐播放列表</string>
<string name="kind_podcast_episode">播客节目</string>
<string name="kind_podcast_metadata">播客秀</string>
<string name="kind_authored_podcasts">创作的播客</string>
<string name="kind_favorite_podcasts">收藏的播客</string>
<string name="kind_badge_awards">徽章奖励</string>
<string name="kind_badge_definitions">徽章定义</string>
<string name="kind_accepted_badge_set">接受的徽章集</string>
+6
View File
@@ -764,6 +764,8 @@
<string name="articles">Articles</string>
<string name="route_music_tracks">Music</string>
<string name="route_music_playlists">Playlists</string>
<string name="route_podcast_episodes">Episodes</string>
<string name="route_podcasts">Podcasts</string>
<string name="new_music_track">New music track</string>
<string name="new_music_playlist">New playlist</string>
<string name="music_track_title_label">Title</string>
@@ -2687,6 +2689,10 @@
<string name="kind_audio_track">Audio Track</string>
<string name="kind_music_track">Music Track</string>
<string name="kind_music_playlist">Music Playlist</string>
<string name="kind_podcast_episode">Podcast Episode</string>
<string name="kind_podcast_metadata">Podcast Show</string>
<string name="kind_authored_podcasts">Authored Podcasts</string>
<string name="kind_favorite_podcasts">Favorite Podcasts</string>
<string name="kind_badge_awards">Badge Awards</string>
<string name="kind_badge_definitions">Badge Definitions</string>
<string name="kind_accepted_badge_set">Accepted Badge Set</string>
@@ -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")
@@ -125,11 +125,17 @@ open class Note(
fun inGatherers() = inGatherers ?: listOf<NotesGatherer>().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) {
@@ -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 <T> LifecycleAwareKeyDataSourceSubscription(
state: T,
dataSource: ComposeSubscriptionManager<T>,
) {
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 <T> LifecycleAwareKeyDataSourceSubscription(
states: List<T>,
dataSource: ComposeSubscriptionManager<T>,
) {
LifecycleAwareSubscription(
key = states,
subscribe = { dataSource.subscribe(states) },
unsubscribe = { dataSource.unsubscribe(states) },
)
}
@Composable
@@ -127,64 +97,51 @@ fun <T : MutableQueryState> LifecycleAwareKeyDataSourceSubscription(
state: T,
dataSource: MutableComposeSubscriptionManager<T>,
) {
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()
}
}
}
@@ -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))
}
}
@@ -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)
@@ -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<Pair<Locale, DateFormat>>()
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)
}
}
@@ -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)
}
}
@@ -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,
@@ -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<ImmutableList<AccountInfo>>(persistentListOf())
val allAccounts: StateFlow<ImmutableList<AccountInfo>> = _allAccounts.asStateFlow()
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
private val _accountState = MutableStateFlow<AccountState>(AccountState.Loading)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null)
@@ -463,6 +465,10 @@ class AccountManager internal constructor(
accountStorage.setCurrentAccount(npub)
}
fun setLoggedOut() {
_accountState.value = AccountState.LoggedOut
}
fun setConnectingRelays() {
_accountState.value = AccountState.ConnectingRelays
}
@@ -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
}
}
@@ -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),
)
}
@@ -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,
)
}
}
/**

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