mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+73
-196
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+5
-5
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+28
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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) }
|
||||
|
||||
+18
@@ -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(
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+3
-3
@@ -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(),
|
||||
|
||||
+3
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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)
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+3
-1
@@ -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 -> "—"
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
+61
-16
@@ -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(
|
||||
|
||||
+5
-3
@@ -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() {
|
||||
|
||||
+2
-2
@@ -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())
|
||||
|
||||
+7
-1
@@ -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)
|
||||
}
|
||||
|
||||
+3
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
) &&
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
+5
@@ -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 ||
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+84
@@ -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)
|
||||
+135
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+84
@@ -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())
|
||||
}
|
||||
}
|
||||
+38
@@ -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)
|
||||
+135
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+82
@@ -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())
|
||||
}
|
||||
}
|
||||
+88
@@ -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)
|
||||
}
|
||||
+87
@@ -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)
|
||||
}
|
||||
+47
@@ -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()
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+96
@@ -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() }
|
||||
}
|
||||
}
|
||||
+47
@@ -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()
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+96
@@ -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() }
|
||||
}
|
||||
}
|
||||
+73
@@ -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()
|
||||
}
|
||||
+110
@@ -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()
|
||||
}
|
||||
+142
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+45
@@ -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()
|
||||
}
|
||||
}
|
||||
+72
@@ -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()
|
||||
}
|
||||
+69
@@ -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()
|
||||
}
|
||||
+50
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
@@ -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
|
||||
|
||||
+4
@@ -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 ||
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+5
-10
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+6
-10
@@ -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() {
|
||||
|
||||
+4
-9
@@ -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))
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+5
-9
@@ -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()
|
||||
|
||||
+5
-8
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
+2
@@ -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) {
|
||||
|
||||
+68
-111
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -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)
|
||||
|
||||
+38
-28
@@ -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,
|
||||
|
||||
+7
-1
@@ -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
|
||||
}
|
||||
|
||||
+74
-254
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+9
-61
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+16
-8
@@ -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
Reference in New Issue
Block a user