feat(desktop): add custom feeds system with creation, pinning, and inline switching

- FeedDefinition data model with FeedSource sealed interface (Filter, Following, Global, DVM, PeopleList, InterestSet, SingleRelay)
- FeedDefinitionRepository with StateFlow, groupedFeeds, pin/unpin/reorder (max 3 pinned)
- JSON serialization via Jackson with round-trip tests (18 unit tests)
- SearchQuery.toFeedDefinition() bridge for creating feeds from search
- FeedBuilderDialog with author search (cache lookup + npub decode), hashtag/relay inputs, kind filter checkboxes, exclude authors/keywords, Cmd+S save, Enter to add
- FeedsDrawerTab in app drawer with edit/delete/pin/unpin actions
- Feeds tab in top header (FilterChips) with inline mode switching
- CustomFeedScreen + DesktopCustomFeedFilter + createCustomFeedSubscription for relay-based custom feed content
- FeedDefinitionEvent (kind 31890) in quartz for future publish/import
- Local persistence via java.util.prefs.Preferences (auto-save on change)
- DeckColumnType.CustomFeed for navigation integration
- Renamed Home to Feeds in nav rail
- Fix: AnimatedGifImage race condition (coerceIn on frame index)
- Fix: search results margins (sidePadding applied consistently)
- Fix: app drawer search includes feeds in results

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-05 14:48:39 +03:00
parent 0874706b31
commit 4010a57e68
29 changed files with 3262 additions and 20 deletions
@@ -0,0 +1,566 @@
# Custom Feeds for Amethyst Desktop
**Date:** 2026-05-04
**Branch:** `feat/desktop-custom-feeds`
**Status:** Planning
**Deepened:** 2026-05-04
## Enhancement Summary
**Research agents used:** feed-patterns, compose-expert, desktop-expert, relay-client, kotlin-expert, nostr-expert, account-state, compose-dnd-research, nip90-research
### Key Improvements from Research
1. Feeds must be **account-scoped** (not java.util.prefs) — supports multi-account + cross-device sync via kind 10090
2. Added **Phase 1.5** (FeedFilter mapping + relay subscriptions) — critical missing layer between data model and UI
3. Use `ImmutableList` from kotlinx.collections.immutable for all FeedSource collections — Compose stability
4. Sync pinned feeds with existing **kind 10090 `FavoriteAlgoFeedsListEvent`** for cross-device persistence
5. Use **Calvin-LL/Reorderable** library for drag-reorder (KMP, proven)
6. Changed chord shortcut from `Cmd+F, 1/2/3` to `Cmd+1/2/3` (standard tab-switch, no conflict)
7. Added EOSE-aware loading states and subscription lifecycle management
---
## Overview
Add custom feed creation, discovery, and management to Amethyst Desktop. Users can create feeds from hashtags, authors, relays, keywords; browse DVM algorithmic feeds; pin top 3 to the sidebar; manage all feeds via the app drawer's new Feeds tab; and optionally publish/import feeds via kind 31890 events.
## Goals
- Intuitive feed lookup and creation (from search or builder)
- Customizable feed navbar (top 3 pinned in sidebar, expandable via drawer)
- DVM marketplace for algorithmic feeds
- Local-first with optional protocol-level sharing (kind 31890 + naddr)
## Non-Goals (for now)
- Set operations (union/intersection/difference)
- WoT-based filtering
- Auto-zapping DVMs
## Design Decisions
| # | Decision |
|---|----------|
| 1 | Top 3 feeds pinned in left sidebar, hard cap |
| 2 | "More" opens app drawer with FEEDS tab |
| 3 | Following + Global pre-created as FeedDefinitions |
| 4 | Local + private first; optional publish to relays |
| 5 | Live stream refresh; periodic poll fallback for DVMs |
| 6 | DVM zap requires confirm popup |
| 7 | Emoji-only feed icons |
| 8 | Drag-reorder in both sidebar and drawer |
| 9 | Reuse existing HomeFeed column rendering (swap filter) |
| 10 | Feed sharing via kind 31890 naddr (publish, copy, paste) |
| 11 | Account-scoped persistence (not machine-global java.util.prefs) |
| 12 | Sync pinned feeds with kind 10090 FavoriteAlgoFeedsListEvent |
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Cmd+Shift+F` | Open drawer on Feeds tab |
| `Cmd+K` | Open drawer (last tab) |
| `Cmd+1/2/3` | Switch to pinned feed 1/2/3 |
| `Cmd+N` (in feeds tab) | Create new feed |
> **Note:** `Cmd+1/2/3` is standard tab-switching (like browsers, Slack). Avoids `Cmd+F` conflict with search. Implemented via `MenuBar` items in a "Feeds" menu — no chord state machine needed.
## Data Model
```kotlin
// commons/src/commonMain/.../feeds/custom/FeedDefinition.kt
@Immutable
data class FeedDefinition(
val id: String, // UUID
val name: String,
val emoji: String, // single emoji as icon
val pinned: Boolean,
val pinOrder: Int, // 0, 1, 2
val source: FeedSource,
val refreshMode: RefreshMode,
val createdAt: Long,
)
@Immutable
sealed interface FeedSource {
@Immutable
data class Filter(
val hashtags: ImmutableList<String>,
val authors: ImmutableList<HexKey>,
val relays: ImmutableList<String>,
val excludeAuthors: ImmutableList<HexKey>,
val excludeKeywords: ImmutableList<String>,
val kinds: ImmutableList<Int>,
) : FeedSource
@Immutable data class PeopleList(val address: ATag) : FeedSource
@Immutable data class InterestSet(val address: ATag) : FeedSource
@Immutable data class DVM(val address: ATag) : FeedSource
@Immutable data class SingleRelay(val url: String) : FeedSource
@Immutable data object Global : FeedSource
@Immutable data object Following : FeedSource
}
enum class RefreshMode {
LIVE_STREAM,
POLL_5MIN,
}
```
### Research Insights: Data Model
- **ImmutableList** (from `kotlinx.collections.immutable`, already in project) required for Compose stability — bare `List` is treated as unstable
- **@Immutable** on sealed interface + all subtypes ensures skip-safe recomposition in FeedCard/sidebar
- **feedKey identity**: use `"custom-${definition.id}"` in generated filters to avoid cache collision between feeds with same parameters but different names
- **DSL builder** for test/programmatic construction:
```kotlin
inline fun feedDefinition(init: FeedDefinitionBuilder.() -> Unit): FeedDefinition =
FeedDefinitionBuilder().apply(init).build()
// Usage in tests:
val feed = feedDefinition {
name = "Bitcoin"
emoji = ""
filter {
hashtags += "bitcoin"
kinds += 1
}
}
```
## Repository & State Management
```kotlin
// commons/src/commonMain/.../feeds/custom/FeedDefinitionRepository.kt
@Stable
class FeedDefinitionRepository(
private val scope: CoroutineScope,
private val serializer: FeedDefinitionSerializer,
) {
private val _feeds = MutableStateFlow<ImmutableList<FeedDefinition>>(persistentListOf())
val feeds: StateFlow<ImmutableList<FeedDefinition>> = _feeds.asStateFlow()
// Pre-computed grouped view for drawer UI
val groupedFeeds: StateFlow<GroupedFeeds> = _feeds.mapLatest { all ->
GroupedFeeds(
pinned = all.filter { it.pinned }.sortedBy { it.pinOrder }.toImmutableList(),
myFeeds = all.filter { !it.pinned && it.source !is FeedSource.DVM }.toImmutableList(),
algoFeeds = all.filter { it.source is FeedSource.DVM }.toImmutableList(),
)
}.distinctUntilChanged().stateIn(scope, SharingStarted.Eagerly, GroupedFeeds.EMPTY)
// Derived for sidebar (only recomposes when pinned change)
val pinnedFeeds: StateFlow<ImmutableList<FeedDefinition>> = groupedFeeds.map { it.pinned }
.distinctUntilChanged().stateIn(scope, SharingStarted.Eagerly, persistentListOf())
// Transient UI events
private val _events = MutableSharedFlow<FeedEvent>(replay = 0)
val events: SharedFlow<FeedEvent> = _events.asSharedFlow()
}
sealed interface FeedEvent {
data class Created(val feed: FeedDefinition) : FeedEvent
data class PinLimitReached(val max: Int) : FeedEvent
}
@Immutable
data class GroupedFeeds(
val pinned: ImmutableList<FeedDefinition>,
val myFeeds: ImmutableList<FeedDefinition>,
val algoFeeds: ImmutableList<FeedDefinition>,
) {
companion object { val EMPTY = GroupedFeeds(persistentListOf(), persistentListOf(), persistentListOf()) }
}
```
### Account-Scoped Persistence
Feeds are per-account, NOT machine-global:
- Serialize alongside `AccountSettings` (same mechanism as `defaultHomeFollowList`, `favoriteAlgoFeeds`)
- On account switch, feed list swaps automatically
- On login, also subscribe to own kind 10090 events to restore pinned feeds from relay
## Navigation Layout
```
+--------+------------------------------------+
| A | |
| | |
| [em1] | <- Pinned feed 1 (active) |
| [em2] | |
| [em3] | Feed Content (reuses HomeFeed) |
| | |
| ... | <- "More feeds" (opens drawer) |
| | |
| | |
| gear | |
+--------+------------------------------------+
```
### Research Insights: Sidebar
- Extend existing `DeckSidebar` params: `pinnedFeeds`, `activeFeedId`, `onSwitchFeed`, `onOpenFeedsDrawer`
- Insert emoji buttons between "Add Column" button and `Spacer(weight=1)`
- Active feed: `primaryContainer` background with `CircleShape`
- Tooltip: `"${feed.name} (Cmd+${index+1})"`
- For 3 items, use simple `Column` + `pointerInput` with `detectDragGestures` (no library needed)
- Drag state: track `draggedIndex` + `dragOffsetY`, swap on threshold cross
## App Drawer Feeds Tab
```
+--- App Drawer (Cmd+K / Cmd+Shift+F) ------+
| Search: [________________________] |
| |
| [Screens] [Workspaces] [Feeds <-active] |
| |
| Pinned (3/3) |
| em Following [unpin] [menu] |
| em Bitcoin [unpin] [edit] [menu]|
| em Trending (DVM) [unpin] [menu] |
| |
| My Feeds |
| em Dev Talk [pin] [edit] [menu] |
| em Memes [pin] [edit] [menu] |
| |
| Algo Feeds |
| em Primal Popular [pin] [menu] |
| |
| [+ Create Feed] [Browse DVMs] |
+---------------------------------------------+
```
### Research Insights: Drawer
- Extend `AppDrawerTab` enum + `AppDrawerState` with filtered feeds
- `Cmd+Shift+F` → add `MenuBar` item with `KeyShortcut(Key.F, meta=true, shift=true)` that sets `showAppDrawer=true` + `appDrawerInitialTab=FEEDS`
- Use `derivedStateOf` for search filtering (avoids recomposition on every keystroke when filtered result unchanged)
- LazyColumn with `key = { it.id }` + `Modifier.animateItem()` for smooth reorder
- Right-click: `onPointerEvent(PointerEventType.Press)` + `isSecondaryPressed``DropdownMenu` (existing pattern in AppDrawer)
- For drawer reorder: use **Calvin-LL/Reorderable** (v3.1.0, full KMP support)
## Feed Creation Paths
| Path | Entry | Result |
|------|-------|--------|
| Search -> Feed | Search results -> "Save as Feed" | SearchQuery -> FeedSource.Filter |
| Builder | Drawer -> "+ Create Feed" | Feed Builder dialog |
| DVM Browse | Drawer -> "Browse DVMs" -> pick | FeedSource.DVM |
| Import | Paste naddr in search/drawer | Fetch kind 31890 -> add |
## Feed Sharing
| Action | Mechanism |
|--------|-----------|
| Publish | Menu -> "Publish to Relays" -> signs kind 31890 |
| Share | After publish -> "Copy naddr" via `NAddress.create(31890, pubkey, dTag, relays)` |
| Import | Paste naddr -> client decodes with `Nip19Parser` -> if kind==31890 render feed card -> "Add to My Feeds" |
## Kind 31890 Event Structure
```
kind: 31890 (addressable replaceable)
content: JSON-serialized FeedSource (see schema below)
tags:
["d", "<feed-id>"]
["title", "<feed name>"]
["emoji", "<single emoji>"]
["alt", "Feed definition: <name>"]
// Discoverability tags (duplicated from content for relay filtering):
["t", "<hashtag>"] // for each hashtag in filter
["p", "<author-hex>"] // for each author in filter
["relay", "<relay-url>"] // for relay-based feeds
["a", "31990:<dvm-pubkey>:<d>"] // DVM reference
["a", "30000:<pubkey>:<d>"] // PeopleList reference
["a", "30015:<pubkey>:<d>"] // InterestSet reference
```
**Content JSON schema:**
```json
{
"type": "filter|people_list|interest_set|dvm|relay|global|following",
"hashtags": ["bitcoin"],
"authors": ["hex..."],
"relays": ["wss://..."],
"exclude_authors": ["hex..."],
"exclude_keywords": ["spam"],
"kinds": [1, 6, 30023],
"refresh": "live|poll_5min",
"source_address": "30000:hex:dtag"
}
```
## Implementation Phases
### Phase 1: Data Model + Persistence
**Location:** `commons/src/commonMain/kotlin/.../feeds/custom/`
- `FeedDefinition` data class with `@Immutable`, `ImmutableList` collections
- `FeedSource` sealed interface with all variants
- `RefreshMode` enum
- `FeedDefinitionRepository` with `StateFlow<ImmutableList<FeedDefinition>>` + `groupedFeeds` + `pinnedFeeds`
- `FeedDefinitionSerializer` (JSON via Jackson, exhaustive `when` on FeedSource for compile safety)
- `FeedDefinitionBuilder` DSL for tests and programmatic creation
- Account-scoped persistence (serialize alongside AccountSettings)
- Pre-create Following + Global as defaults on first launch
- Unit tests for serialization round-trip + builder DSL
### Phase 1.5: FeedFilter Mapping + Relay Subscriptions
**Location:** `commons/src/commonMain/kotlin/.../feeds/custom/`
This is the critical bridge between data model and UI rendering.
**FeedFilter per FeedSource variant:**
| FeedSource | Filter Type | Base Class |
|------------|-------------|------------|
| Filter | `CustomFilterFeedFilter` | `AdditiveComplexFeedFilter` |
| Following | Reuse existing `HomeFeedFilter` | — |
| Global | Reuse existing `GlobalFeedFilter` | — |
| PeopleList | Resolve ATag -> extract pubkeys -> author filter | `AdditiveComplexFeedFilter` |
| InterestSet | Resolve ATag -> extract hashtags -> tag filter | `AdditiveComplexFeedFilter` |
| DVM | `DvmFeedFilter` (non-additive, results from external) | `FeedFilter` |
| SingleRelay | `CustomFilterFeedFilter` (targeted to one relay) | `AdditiveComplexFeedFilter` |
**FeedFilterFactory:**
```kotlin
class FeedFilterFactory {
fun createFilter(definition: FeedDefinition): IFeedFilter<Note> = when (definition.source) {
is FeedSource.Filter -> CustomFilterFeedFilter(definition)
is FeedSource.Following -> HomeFeedFilter(account)
is FeedSource.Global -> GlobalFeedFilter()
// ... etc
}
}
```
**Key rules:**
- `feedKey() = "custom-${definition.id}"` — unique per feed, avoids cache collision
- `excludeAuthors`/`excludeKeywords` applied client-side in `applyFilter()`, not at relay level
- Unit test: `applyFilter(event)` must match what `feed()` would include/exclude
**Relay subscription assembler:**
```kotlin
class CustomFeedFilterAssembler(private val source: FeedSource.Filter) {
fun toFilter(): Filter = filter {
if (source.kinds.isNotEmpty()) kinds(source.kinds)
if (source.authors.isNotEmpty()) authors(source.authors.toSet())
if (source.hashtags.isNotEmpty()) tags("t", source.hashtags.toSet())
limit(200)
}
}
```
**ViewModel selection:**
- Standard feeds (hashtags, authors, relays) -> `FeedViewModel`
- DVM feeds -> `FeedViewModel` with poll-based invalidation
- PeopleList feeds -> `ListChangeFeedViewModel` (membership changes)
**EOSE-aware loading state:**
```kotlin
class CustomFeedSubscriptionState(
val events: StateFlow<List<Note>>,
val eoseReceived: StateFlow<Boolean>,
val lastRefreshed: StateFlow<Instant?>,
)
```
**Subscription lifecycle:**
- Only the ACTIVE feed has a live subscription
- Pinned feeds not currently displayed = NO open subscription
- On switch: old feed `unsubscribe()`, new feed `subscribe()`
- For POLL_5MIN: subscribe -> wait EOSE -> unsubscribe -> timer -> repeat
**Invalidation signals per FeedSource:**
- `Filter` with authors -> invalidate when those authors' notes arrive in LocalCache
- `Following` -> invalidate on `Account.followListFlow` change
- `PeopleList` -> invalidate when referenced list event updates
### Phase 2: Sidebar Pinned Feeds
**Location:** `desktopApp/src/jvmMain/.../deck/DeckSidebar.kt`
- Add params to `DeckSidebar`: `pinnedFeeds`, `activeFeedId`, `onSwitchFeed`, `onOpenFeedsDrawer`
- Insert pinned feed emoji buttons between "Add Column" and spacer
- Active state: `primaryContainer` background + `CircleShape`
- Click switches active feed (triggers subscription swap)
- Drag-to-reorder: `detectDragGestures` on each item (3 items, Column, no library needed)
- Tooltip with shortcut hint: `"${feed.name} (Cmd+${index+1})"`
- "More" button (MaterialSymbols.MoreHoriz) opens drawer on Feeds tab
- Wire `Cmd+1/2/3` via `MenuBar` items in "Feeds" menu (OS-aware: meta on macOS, ctrl on others)
### Phase 3: App Drawer Feeds Tab
**Location:** `desktopApp/src/jvmMain/.../deck/AppDrawer.kt`
- Add `FEEDS` to `AppDrawerTab` enum
- Extend `AppDrawerState` with `filteredFeeds()` method + keyboard nav for 3rd tab
- `Cmd+Shift+F` → MenuBar item that opens drawer on Feeds tab (pass `appDrawerInitialTab`)
- Feed list grouped via `groupedFeeds` StateFlow (pre-computed in repository)
- Search: `derivedStateOf` filtering by name/emoji
- Pin/unpin buttons (grayed out at 3 cap, emit `FeedEvent.PinLimitReached`)
- Right-click: `onPointerEvent` + `isSecondaryPressed` -> DropdownMenu (Edit, Duplicate, Delete, Publish)
- Drag-reorder in pinned section: Calvin-LL/Reorderable v3.1.0 with `LazyColumn` + `key = { it.id }`
- `animateItem()` for smooth movement on reorder
### Phase 4: Feed Builder Dialog
**Location:** `commons/src/commonMain/.../feeds/custom/ui/` (composable) + `desktopApp` (host)
**State hoisting pattern:**
```kotlin
@Stable
class FeedBuilderState(initial: FeedDefinition?) {
var name by mutableStateOf(initial?.name ?: "")
var emoji by mutableStateOf(initial?.emoji ?: "")
val hashtags = mutableStateListOf<String>()
val authors = mutableStateListOf<HexKey>()
val relays = mutableStateListOf<String>()
val excludeAuthors = mutableStateListOf<HexKey>()
val excludeKeywords = mutableStateListOf<String>()
// ...
fun toDefinition(): FeedDefinition = ...
}
```
- Stateless composable: `FeedBuilderDialog(initialDefinition, onSave, onDismiss)`
- Internal state via `rememberFeedBuilderState(initial)`
- Emoji picker: simple grid of common emojis in `FlowRow` (use Emoji.kt data library for full Unicode set)
- Author autocomplete via ViewModel (never query LocalCache directly from composable)
- `dismissOnBackPress = true, dismissOnClickOutside = false` (prevent accidental data loss)
- Material3: `AlertDialog` or `Dialog` with `surface` bg
### Phase 5: Search -> Feed Bridge
**Location:** `commons/src/commonMain/.../feeds/custom/`
- `SearchQuery.toFeedDefinition()` extension
- Maps hashtag operators -> `FeedSource.Filter.hashtags`
- Maps from: operators -> `FeedSource.Filter.authors`
- Maps relay: operators -> `FeedSource.Filter.relays`
- Maps exclude operators -> excludeAuthors/excludeKeywords
- Maps kind: operators -> `FeedSource.Filter.kinds`
- "Save as Feed" button in search results UI
- Uses `FeedDefinitionBuilder` DSL internally
### Phase 6: DVM Marketplace
**Location:** `desktopApp/src/jvmMain/.../feeds/`
- Browse kind 31990 `AppDefinitionEvent` filtered by `isTaggedKind(5300)` (existing Quartz class)
- List with name, description, author, cost indicator
- Preview: send kind 5300 request, show results in preview panel
- "Add to My Feeds" creates `FeedSource.DVM(address)` entry
- Zap confirm popup when kind 7000 status = "payment-required" with `firstAmount()` + invoice
- DVM request goes to DVM's advertised relays (from kind 31990 `relay` tags)
- Response subscription listens on both user's relays AND DVM's relays
- Use `MetadataPreloader` for bulk-fetching author metadata of returned notes
- Reuse existing `NIP90ContentDiscoveryRequestEvent.build()` pattern from Quartz
### Phase 7: Publish/Import (kind 31890)
**Location:** `quartz/src/commonMain/.../feedDefinition/` (event type) + `commons` (UI)
- `FeedDefinitionEvent` extends `BaseAddressableEvent` (kind 31890)
- d-tag = feed UUID, content = JSON FeedSource, tags for discoverability
- Serialize `FeedDefinition` -> event via `FeedDefinitionEvent.build(signer, definition)`
- Parse kind 31890 events -> `FeedDefinition` via content JSON deserialization
- "Publish to Relays" action in feed context menu (signs + publishes)
- "Copy naddr" via `NAddress.create(31890, pubkey, dTag, relays)`
- Import: `Nip19Parser` detects naddr with kind 31890 -> fetch event -> render feed card preview
- "Add to My Feeds" clones with new UUID (marks as not-published-by-me)
- On login: subscribe to own kind 31890 + kind 10090 to restore from relay
### Cross-Device Sync (kind 10090)
- Sync pinned feed addresses with existing `FavoriteAlgoFeedsListEvent` (kind 10090)
- On pin/unpin, update kind 10090 event with current pinned feed addresses
- On login/restore, fetch own kind 10090, resolve `AddressBookmark` entries, populate sidebar
- This reuses the existing protocol — no new event kind needed for pin sync
## File Map (expected new files)
```
commons/src/commonMain/kotlin/.../feeds/custom/
FeedDefinition.kt # @Immutable data class + FeedSource + RefreshMode
FeedDefinitionRepository.kt # StateFlow-based, account-scoped
FeedDefinitionSerializer.kt # JSON serialization (exhaustive when)
FeedDefinitionBuilder.kt # DSL for tests + SearchQuery bridge
GroupedFeeds.kt # @Immutable pre-computed grouping
FeedEvent.kt # SharedFlow events (PinLimitReached, etc.)
SearchQueryToFeed.kt # SearchQuery.toFeedDefinition() extension
filters/
CustomFilterFeedFilter.kt # AdditiveComplexFeedFilter for FeedSource.Filter
FeedFilterFactory.kt # FeedSource -> IFeedFilter<Note> mapping
assemblers/
CustomFeedFilterAssembler.kt # FeedSource.Filter -> relay Filter
PeopleListFilterAssembler.kt # Resolve ATag -> author set -> Filter
DvmFeedSubscribable.kt # NIP-90 request/response lifecycle
ui/
FeedBuilderDialog.kt # Shared composable (stateless)
FeedBuilderState.kt # @Stable state holder
FeedCard.kt # Feed preview card
EmojiPicker.kt # Simple emoji grid (FlowRow)
desktopApp/src/jvmMain/kotlin/.../deck/
FeedSidebarSection.kt # Pinned feeds in sidebar
FeedDrawerTab.kt # FEEDS tab content
DvmMarketplace.kt # DVM browse UI
quartz/src/commonMain/kotlin/.../feedDefinition/
FeedDefinitionEvent.kt # kind 31890 (BaseAddressableEvent)
commons/src/commonTest/kotlin/.../feeds/custom/
FeedDefinitionSerializerTest.kt
FeedDefinitionBuilderTest.kt
SearchQueryToFeedTest.kt
CustomFeedFilterAssemblerTest.kt
```
## Dependencies on Existing Code
| Component | Location | Usage |
|-----------|----------|-------|
| `TopFilter` | `amethyst/.../AccountSettings.kt` | Reference; `FeedSource.toTopFilter()` for bridge |
| `FavoriteAlgoFeedsOrchestrator` | `amethyst/.../algoFeeds/` | Extract to commons for DVM reuse |
| `FavoriteAlgoFeedsListEvent` (kind 10090) | `quartz/.../nip51Lists/` | Pinned feed sync |
| `NIP90ContentDiscoveryRequestEvent` | `quartz/.../nip90Dvms/` | DVM request building |
| `AppDefinitionEvent` (kind 31990) | `quartz/.../nip89AppHandlers/` | DVM marketplace discovery |
| `NAddress` | `quartz/.../nip19Bech32/entities/` | naddr encode/decode |
| `Nip19Parser` | `quartz/.../nip19Bech32/` | Detect pasted naddr |
| `SearchQuery` / `QueryParser` | `commons/.../search/` | Phase 5 bridge |
| `AppDrawer` / `AppDrawerTab` | `desktopApp/.../deck/AppDrawer.kt` | Phase 3 integration |
| `DeckSidebar` | `desktopApp/.../deck/DeckSidebar.kt` | Phase 2 integration |
| `PinnedNavBarState` | `desktopApp/.../deck/PinnedNavBarState.kt` | Reference pattern for pin state |
| `HomeFeed` rendering | `desktopApp/.../home/` | Phase 2 content reuse |
| `BaseAddressableEvent` | `quartz/.../nip01Core/core/` | Base for kind 31890 |
| `PeopleListEvent` / `InterestSetEvent` | `quartz/.../nip51Lists/` | Resolve ATag -> members |
| `MetadataPreloader` | `commons/.../relayClient/` | Bulk metadata fetch for feed results |
| `FeedMetadataCoordinator` | `commons/.../relayClient/` | Coordinate metadata for visible notes |
| `ComposeSubscriptionManager` | `commons/.../relayClient/` | Subscription lifecycle |
## External Dependencies
| Library | Version | Usage |
|---------|---------|-------|
| `sh.calvin.reorderable:reorderable` | 3.1.0 | Drag-reorder in drawer LazyColumn |
| `org.kodein.emoji:emoji-compose` (Emoji.kt) | latest | Emoji data for picker grid |
| `kotlinx.collections.immutable` | (already in project) | ImmutableList for stability |
## Risk & Mitigations
| Risk | Mitigation |
|------|------------|
| DVM latency makes feeds feel broken | Show loading skeleton + "last refreshed" timestamp + EOSE state |
| Kind 31890 NIP still in draft | Keep publish optional; local-first always works |
| Sidebar drag-reorder complexity | Only 3 items — simple `detectDragGestures`, no library |
| Feed builder autocomplete for authors | Reuse existing user search via ViewModel (never direct LocalCache query) |
| Account switching breaks feed state | Account-scoped repository auto-swaps with account |
| Kind 10090 sync conflicts | Last-write-wins (same as other replaceable events) |
| Preferences 8KB limit (if used for temp storage) | JSON chunking pattern or switch to account serialization |
| DVM payment format inconsistency | Support bolt11 from amount tag + NIP-57 zap; show raw amount if format unclear |
@@ -0,0 +1,170 @@
# Custom Feeds — Testing Sheet
**Branch:** `feat/desktop-custom-feeds`
**Run:** `./gradlew :desktopApp:run`
## Prerequisites
Wiring is complete. `FeedDefinitionRepository` is provided via `CompositionLocalProvider` in `Main.kt`.
Default feeds (Following + Global) are loaded on startup.
---
## Phase 1: Data Model + Serialization (Unit Tests)
```bash
./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.feeds.custom.*"
```
| # | Test | Expected |
|---|------|----------|
| 1 | `FeedDefinitionSerializerTest.roundTripFilterSource` | Serialize/deserialize Filter with all fields |
| 2 | `FeedDefinitionSerializerTest.roundTripGlobalSource` | Global source round-trip |
| 3 | `FeedDefinitionSerializerTest.roundTripFollowingSource` | Following source round-trip |
| 4 | `FeedDefinitionSerializerTest.roundTripDvmSource` | DVM source round-trip |
| 5 | `FeedDefinitionSerializerTest.roundTripPeopleListSource` | PeopleList source round-trip |
| 6 | `FeedDefinitionSerializerTest.roundTripInterestSetSource` | InterestSet source round-trip |
| 7 | `FeedDefinitionSerializerTest.roundTripSingleRelaySource` | SingleRelay source round-trip |
| 8 | `FeedDefinitionSerializerTest.multipleFeeds` | Multiple feeds in single JSON |
| 9 | `FeedDefinitionSerializerTest.emptyJsonReturnsEmptyList` | Empty/blank input |
| 10 | `FeedDefinitionSerializerTest.defaultFeedsAreValid` | Default feeds (Following+Global) pinned |
| 11 | `SearchQueryToFeedTest.convertsHashtagsToFeedFilter` | SearchQuery hashtags -> FeedSource.Filter |
| 12 | `SearchQueryToFeedTest.convertsAuthorsToFeedFilter` | SearchQuery authors -> FeedSource.Filter |
| 13 | `SearchQueryToFeedTest.convertsExcludeTerms` | Exclude terms mapping |
| 14 | `SearchQueryToFeedTest.convertsKinds` | Kind mapping |
| 15 | `SearchQueryToFeedTest.canBecomeFeedWithHashtags` | canBecomeFeed = true |
| 16 | `SearchQueryToFeedTest.canBecomeFeedWithAuthors` | canBecomeFeed = true |
| 17 | `SearchQueryToFeedTest.canNotBecomeFeedEmpty` | canBecomeFeed = false |
| 18 | `SearchQueryToFeedTest.canNotBecomeFeedTextOnly` | canBecomeFeed = false |
---
## Phase 2/3: Sidebar + App Drawer Feeds Tab (Manual)
### App Drawer Integration
| # | Action | Expected |
|---|--------|----------|
| 1 | Open app drawer (Cmd+K) | Drawer opens, shows 3 tabs: Screens, Workspaces, Feeds |
| 2 | Click "Feeds" tab | Feed list appears (empty initially + defaults after wiring) |
| 3 | Cmd+Shift+F | Drawer opens directly on Feeds tab |
| 4 | Feeds tab shows sections | "Pinned", "My Feeds", "Algo Feeds" sections visible |
| 5 | Click a feed row | Drawer closes, main content switches to that feed |
### Feed CRUD (in Feeds tab)
| # | Action | Expected |
|---|--------|----------|
| 6 | Click "+ Create Feed" | Feed Builder dialog opens |
| 7 | Enter name + emoji + hashtags | Form validates (Save enabled when name + at least 1 source) |
| 8 | Click "Save" | Dialog closes, new feed appears in "My Feeds" section |
| 9 | Click "Pin" on a feed | Feed moves to "Pinned" section (max 3) |
| 10 | Try pinning a 4th feed | Pin fails (PinLimitReached event, button stays) |
| 11 | Click "Unpin" on a pinned feed | Feed moves back to "My Feeds" |
---
## Phase 4: Feed Builder Dialog (Manual)
| # | Action | Expected |
|---|--------|----------|
| 1 | Open builder, leave empty | "Save" button disabled |
| 2 | Enter name only | Still disabled (needs source) |
| 3 | Enter name + add hashtag "bitcoin" | "Save" enabled |
| 4 | Add multiple hashtags | All show as chips, removable by click |
| 5 | Add author pubkey | Shows as chip |
| 6 | Add relay URL | Shows as chip |
| 7 | Add exclude keyword | Shows as chip in exclude section |
| 8 | Toggle refresh mode (Live / Every 5 min) | Selection changes |
| 9 | Click "Cancel" | Dialog dismissed, no feed created |
| 10 | Click "Save" | Feed created with all specified params |
---
## Phase 5: Search -> Feed Bridge (Manual)
| # | Action | Expected |
|---|--------|----------|
| 1 | Search "#bitcoin" in search screen | Results appear |
| 2 | Programmatically: `SearchQuery(hashtags=listOf("bitcoin")).canBecomeFeed()` | Returns true |
| 3 | `query.toFeedDefinition("BTC", "₿")` | Creates FeedDefinition with FeedSource.Filter(hashtags=["bitcoin"]) |
*(Note: "Save as Feed" button in search UI is not yet wired — the bridge logic exists but UI button pending)*
---
## Phase 1.5: Custom Feed Content (Manual)
| # | Action | Expected |
|---|--------|----------|
| 1 | Create feed with hashtag "nostr" | Feed created |
| 2 | Select that feed from drawer | Content area shows FeedScreen |
| 3 | Wait for events | Notes containing #nostr appear (from relay subscription) |
| 4 | Create feed with author pubkey | Only notes from that author appear |
| 5 | Create feed with excludeKeyword "spam" | Notes containing "spam" filtered out |
| 6 | Switch between feeds | Content updates, old subscription closed |
| 7 | Feed with specific relay URL | Subscription targets only that relay |
---
## Phase 7: Kind 31890 Event (Compile-level)
| # | Check | Expected |
|---|-------|----------|
| 1 | `FeedDefinitionEvent.KIND == 31890` | Correct |
| 2 | `FeedDefinitionEvent` extends `BaseAddressableEvent` | Has `dTag()`, `address()`, `addressTag()` |
| 3 | `title()` extracts from tags | Parses `["title", "..."]` tag |
| 4 | `emoji()` extracts from tags | Parses `["emoji", "..."]` tag |
| 5 | `feedConfigJson()` returns content | JSON-serialized FeedSource |
---
## Compilation Verification
```bash
# Full compile (all modules)
./gradlew :quartz:compileKotlinJvm :commons:compileKotlinJvm :desktopApp:compileKotlin
# Unit tests
./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.feeds.custom.*"
# Code formatting
./gradlew spotlessApply
```
---
## Wiring TODO (Required for Manual Testing)
The `FeedDefinitionRepository` needs to be instantiated and provided via `CompositionLocalProvider` in `Main.kt`. Steps:
1. In `Main.kt` `App()` function (around line 710), create:
```kotlin
val feedRepository = remember { FeedDefinitionRepository(appScope) }
LaunchedEffect(Unit) { feedRepository.load(defaultFeeds()) }
```
2. In the `CompositionLocalProvider` block (around line 1166), add:
```kotlin
LocalFeedRepository provides feedRepository,
LocalFeedScope provides appScope,
```
3. This enables:
- `FeedsDrawerTab` to read/write feeds
- `CustomFeedScreen` to resolve feed definitions by ID
---
## Known Limitations (not bugs)
| Item | Status |
|------|--------|
| DVM marketplace (Phase 6) | Button wired, content shows "coming soon" |
| PeopleList/InterestSet resolution | Shows "coming soon" — needs ATag resolution |
| Kind 10090 cross-device sync | Not wired yet — local-only |
| Feed publish/import (naddr) | Event class exists, publish UI not wired |
| Sidebar pinned feed emojis | Needs `PinnedNavBarState` integration for feed-specific items |
| Account-scoped persistence | Currently in-memory only (needs serialize to account settings) |
| Cmd+1/2/3 shortcuts | Not wired yet (needs MenuBar items in Main.kt) |
| Drag-to-reorder | Repository supports it, UI gesture handlers pending |
@@ -0,0 +1,93 @@
# Feed Builder Enhancements
**Date:** 2026-05-05
**Status:** Implementation
## Scope
Enhance `FeedBuilderDialog` with 6 items:
1. npub auto-decode (paste npub -> hex + display name)
2. Author search (type name -> search LocalCache -> pick)
3. Kind filter checkboxes (Notes, Reposts, Articles)
4. Edit + Delete feeds (CRUD completeness)
5. "Save as Feed" button in search results
6. Exclude authors field in dialog
## Implementation
### 1. npub Auto-Decode
**Where:** `FeedBuilderDialog.kt` ChipInputField for Authors
**Logic:**
- On add: if input starts with `npub1`, decode via `decodePublicKeyAsHexOrNull()`
- If decode succeeds, add hex to authors list
- Show display name in chip (resolve from LocalCache)
### 2. Author Search
**Where:** New `AuthorSearchField` composable in `FeedBuilderDialog.kt`
**Flow:**
- Replace plain text field for authors with search field
- Type >= 2 chars -> filter `DesktopLocalCache.users` by displayName/nip05
- Show dropdown with matching profiles (avatar placeholder + name + npub short)
- Click adds hex to authors list
- Still allow raw npub/hex paste (falls through to auto-decode)
**Data source:** `DesktopLocalCache.users` (already populated from metadata subscriptions)
### 3. Kind Filter Checkboxes
**Where:** `FeedBuilderDialog.kt`, new section between relays and exclude
**UI:** FlowRow of FilterChips:
- [x] Notes (kind 1)
- [x] Reposts (kind 6, 16)
- [ ] Articles (kind 30023)
- [ ] Highlights (kind 9802)
- [ ] Reactions (kind 7)
Default: Notes + Reposts checked. Maps to `FeedBuilderState.kinds`.
### 4. Edit + Delete in Drawer
**Where:** `FeedsDrawerTab.kt`
**Edit:** Add edit button on each FeedRow -> opens `FeedBuilderDialog(initial = feed)`
- On save: calls `feedRepository.update(feed)`
**Delete:** Add delete in context or as swipe action
- Confirm dialog: "Delete feed X?"
- Calls `feedRepository.delete(feed.id)`
### 5. "Save as Feed" from Search
**Where:** `SearchScreen.kt` or `SearchResultsList.kt`
**UI:** Show "Save as Feed" button when `query.canBecomeFeed()` is true
- Button appears in the header area, next to the search bar actions
- Click opens `FeedBuilderDialog` pre-filled from `query.toFeedDefinition()`
### 6. Exclude Authors in Dialog
**Where:** `FeedBuilderDialog.kt`
**UI:** Same chip input pattern as regular authors but for excludes
- Uses same author search/npub-decode logic
- Maps to `FeedBuilderState.excludeAuthors`
## File Changes
| File | Change |
|------|--------|
| `FeedBuilderDialog.kt` | Author search field, npub decode, kind checkboxes, exclude authors |
| `FeedsDrawerTab.kt` | Edit/delete buttons on feed rows |
| `SearchScreen.kt` | "Save as Feed" button |
| `FeedBuilderState.kt` | No changes needed (already has all fields) |
## Dependencies
- `decodePublicKeyAsHexOrNull` from `quartz/nip19Bech32`
- `DesktopLocalCache.users` for profile search
- `SearchQuery.canBecomeFeed()` + `toFeedDefinition()` already exist
@@ -0,0 +1,93 @@
/*
* 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.feeds.custom
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.toImmutableList
@Stable
class FeedBuilderState(
initial: FeedDefinition? = null,
) {
var name by mutableStateOf(initial?.name ?: "")
var emoji by mutableStateOf(initial?.emoji ?: "")
var refreshMode by mutableStateOf(initial?.refreshMode ?: RefreshMode.LIVE_STREAM)
val hashtags =
mutableStateListOf<String>().apply {
(initial?.source as? FeedSource.Filter)?.hashtags?.let { addAll(it) }
}
val authors =
mutableStateListOf<HexKey>().apply {
(initial?.source as? FeedSource.Filter)?.authors?.let { addAll(it) }
}
val relays =
mutableStateListOf<String>().apply {
(initial?.source as? FeedSource.Filter)?.relays?.let { addAll(it) }
}
val excludeAuthors =
mutableStateListOf<HexKey>().apply {
(initial?.source as? FeedSource.Filter)?.excludeAuthors?.let { addAll(it) }
}
val excludeKeywords =
mutableStateListOf<String>().apply {
(initial?.source as? FeedSource.Filter)?.excludeKeywords?.let { addAll(it) }
}
val kinds =
mutableStateListOf<Int>().apply {
(initial?.source as? FeedSource.Filter)?.kinds?.let { addAll(it) }
}
val isValid: Boolean
get() = name.isNotBlank() && (hashtags.isNotEmpty() || authors.isNotEmpty() || relays.isNotEmpty())
private val editId: String? = initial?.id
fun toDefinition(): FeedDefinition {
val source =
FeedSource.Filter(
hashtags = hashtags.toImmutableList(),
authors = authors.toImmutableList(),
relays = relays.toImmutableList(),
excludeAuthors = excludeAuthors.toImmutableList(),
excludeKeywords = excludeKeywords.toImmutableList(),
kinds = kinds.toImmutableList(),
)
return FeedDefinition(
id =
editId ?: java.util.UUID
.randomUUID()
.toString(),
name = name,
emoji = emoji,
pinned = false,
pinOrder = Int.MAX_VALUE,
source = source,
refreshMode = refreshMode,
createdAt = System.currentTimeMillis() / 1000,
)
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.feeds.custom
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
data class FeedDefinition(
val id: String,
val name: String,
val emoji: String,
val pinned: Boolean,
val pinOrder: Int,
val source: FeedSource,
val refreshMode: RefreshMode,
val createdAt: Long,
)
@Immutable
sealed interface FeedSource {
@Immutable
data class Filter(
val hashtags: ImmutableList<String> = persistentListOf(),
val authors: ImmutableList<HexKey> = persistentListOf(),
val relays: ImmutableList<String> = persistentListOf(),
val excludeAuthors: ImmutableList<HexKey> = persistentListOf(),
val excludeKeywords: ImmutableList<String> = persistentListOf(),
val kinds: ImmutableList<Int> = persistentListOf(),
) : FeedSource
@Immutable
data class PeopleList(
val kind: Int,
val pubkey: HexKey,
val dTag: String,
) : FeedSource
@Immutable
data class InterestSet(
val kind: Int,
val pubkey: HexKey,
val dTag: String,
) : FeedSource
@Immutable
data class DVM(
val kind: Int,
val pubkey: HexKey,
val dTag: String,
) : FeedSource
@Immutable
data class SingleRelay(
val url: String,
) : FeedSource
@Immutable
data object Global : FeedSource
@Immutable
data object Following : FeedSource
}
enum class RefreshMode {
LIVE_STREAM,
POLL_5MIN,
}
@@ -0,0 +1,125 @@
/*
* 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.feeds.custom
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.toImmutableList
class FeedDefinitionBuilder {
var name: String = ""
var emoji: String = ""
var refreshMode: RefreshMode = RefreshMode.LIVE_STREAM
private var source: FeedSource? = null
fun filter(init: FilterBuilder.() -> Unit) {
source = FilterBuilder().apply(init).build()
}
fun fromPeopleList(
kind: Int = 30000,
pubkey: HexKey,
dTag: String,
) {
source = FeedSource.PeopleList(kind, pubkey, dTag)
}
fun fromDvm(
kind: Int = 31990,
pubkey: HexKey,
dTag: String,
) {
source = FeedSource.DVM(kind, pubkey, dTag)
}
fun fromRelay(url: String) {
source = FeedSource.SingleRelay(url)
}
fun global() {
source = FeedSource.Global
}
fun following() {
source = FeedSource.Following
}
fun build(): FeedDefinition =
FeedDefinition(
id = generateId(),
name = name,
emoji = emoji,
pinned = false,
pinOrder = Int.MAX_VALUE,
source = source ?: error("FeedDefinition requires a source"),
refreshMode = refreshMode,
createdAt = System.currentTimeMillis() / 1000,
)
private fun generateId(): String =
java.util.UUID
.randomUUID()
.toString()
}
class FilterBuilder {
val hashtags = mutableListOf<String>()
val authors = mutableListOf<HexKey>()
val relays = mutableListOf<String>()
val excludeAuthors = mutableListOf<HexKey>()
val excludeKeywords = mutableListOf<String>()
val kinds = mutableListOf<Int>()
fun build(): FeedSource.Filter =
FeedSource.Filter(
hashtags = hashtags.toImmutableList(),
authors = authors.toImmutableList(),
relays = relays.toImmutableList(),
excludeAuthors = excludeAuthors.toImmutableList(),
excludeKeywords = excludeKeywords.toImmutableList(),
kinds = kinds.toImmutableList(),
)
}
inline fun feedDefinition(init: FeedDefinitionBuilder.() -> Unit): FeedDefinition = FeedDefinitionBuilder().apply(init).build()
fun defaultFeeds(): List<FeedDefinition> =
listOf(
FeedDefinition(
id = "default-following",
name = "Following",
emoji = "\uD83C\uDFE0",
pinned = true,
pinOrder = 0,
source = FeedSource.Following,
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 0L,
),
FeedDefinition(
id = "default-global",
name = "Global",
emoji = "\uD83C\uDF10",
pinned = true,
pinOrder = 1,
source = FeedSource.Global,
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 0L,
),
)
@@ -0,0 +1,162 @@
/*
* 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.feeds.custom
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
const val MAX_PINNED_FEEDS = 3
@Stable
class FeedDefinitionRepository(
private val scope: CoroutineScope,
) {
private val _feeds = MutableStateFlow<ImmutableList<FeedDefinition>>(persistentListOf())
val feeds: StateFlow<ImmutableList<FeedDefinition>> = _feeds.asStateFlow()
val groupedFeeds: StateFlow<GroupedFeeds> =
_feeds
.map { all ->
GroupedFeeds(
pinned = all.filter { it.pinned }.sortedBy { it.pinOrder }.toImmutableList(),
myFeeds = all.filter { !it.pinned && it.source !is FeedSource.DVM }.toImmutableList(),
algoFeeds = all.filter { it.source is FeedSource.DVM }.toImmutableList(),
)
}.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, GroupedFeeds.EMPTY)
val pinnedFeeds: StateFlow<ImmutableList<FeedDefinition>> =
groupedFeeds
.map { it.pinned }
.distinctUntilChanged()
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
private val _events = MutableSharedFlow<FeedEvent>(replay = 0)
val events: SharedFlow<FeedEvent> = _events.asSharedFlow()
fun load(feeds: List<FeedDefinition>) {
_feeds.value = feeds.toImmutableList()
}
fun snapshot(): List<FeedDefinition> = _feeds.value
suspend fun add(feed: FeedDefinition) {
_feeds.value = (_feeds.value + feed).toImmutableList()
_events.emit(FeedEvent.Created(feed))
}
suspend fun update(feed: FeedDefinition) {
_feeds.value =
_feeds.value
.map { if (it.id == feed.id) feed else it }
.toImmutableList()
}
suspend fun delete(id: String) {
_feeds.value = _feeds.value.filter { it.id != id }.toImmutableList()
}
suspend fun pin(id: String): Boolean {
val currentPinned = _feeds.value.count { it.pinned }
if (currentPinned >= MAX_PINNED_FEEDS) {
_events.emit(FeedEvent.PinLimitReached(MAX_PINNED_FEEDS))
return false
}
_feeds.value =
_feeds.value
.map {
if (it.id == id) it.copy(pinned = true, pinOrder = currentPinned) else it
}.toImmutableList()
return true
}
suspend fun unpin(id: String) {
_feeds.value =
_feeds.value
.map { if (it.id == id) it.copy(pinned = false, pinOrder = Int.MAX_VALUE) else it }
.toImmutableList()
// Reindex remaining pinned
reindexPinned()
}
suspend fun reorderPinned(
fromIndex: Int,
toIndex: Int,
) {
val pinned =
_feeds.value
.filter { it.pinned }
.sortedBy { it.pinOrder }
.toMutableList()
if (fromIndex !in pinned.indices || toIndex !in pinned.indices) return
val item = pinned.removeAt(fromIndex)
pinned.add(toIndex, item)
val reindexed = pinned.mapIndexed { i, feed -> feed.copy(pinOrder = i) }.associateBy { it.id }
_feeds.value =
_feeds.value
.map { reindexed[it.id] ?: it }
.toImmutableList()
}
private fun reindexPinned() {
val pinned = _feeds.value.filter { it.pinned }.sortedBy { it.pinOrder }
val reindexed = pinned.mapIndexed { i, feed -> feed.copy(pinOrder = i) }.associateBy { it.id }
_feeds.value =
_feeds.value
.map { reindexed[it.id] ?: it }
.toImmutableList()
}
}
sealed interface FeedEvent {
data class Created(
val feed: FeedDefinition,
) : FeedEvent
data class PinLimitReached(
val max: Int,
) : FeedEvent
}
@Immutable
data class GroupedFeeds(
val pinned: ImmutableList<FeedDefinition>,
val myFeeds: ImmutableList<FeedDefinition>,
val algoFeeds: ImmutableList<FeedDefinition>,
) {
companion object {
val EMPTY = GroupedFeeds(persistentListOf(), persistentListOf(), persistentListOf())
}
}
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import kotlinx.collections.immutable.toImmutableList
object FeedDefinitionSerializer {
private val mapper = ObjectMapper()
fun serializeList(feeds: List<FeedDefinition>): String {
val array = mapper.createArrayNode()
feeds.forEach { feed -> array.add(serializeFeed(feed)) }
return mapper.writeValueAsString(array)
}
fun deserializeList(json: String): List<FeedDefinition> {
if (json.isBlank()) return emptyList()
val array = mapper.readTree(json) as? ArrayNode ?: return emptyList()
return array.mapNotNull { node -> deserializeFeed(node) }
}
private fun serializeFeed(feed: FeedDefinition): ObjectNode =
mapper.createObjectNode().apply {
put("id", feed.id)
put("name", feed.name)
put("emoji", feed.emoji)
put("pinned", feed.pinned)
put("pinOrder", feed.pinOrder)
put("refreshMode", feed.refreshMode.name)
put("createdAt", feed.createdAt)
set<ObjectNode>("source", serializeSource(feed.source))
}
private fun deserializeFeed(node: JsonNode): FeedDefinition? {
val id = node.get("id")?.asText() ?: return null
val name = node.get("name")?.asText() ?: return null
val emoji = node.get("emoji")?.asText() ?: ""
val pinned = node.get("pinned")?.asBoolean() ?: false
val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE
val refreshMode =
node.get("refreshMode")?.asText()?.let {
try {
RefreshMode.valueOf(it)
} catch (_: Exception) {
RefreshMode.LIVE_STREAM
}
} ?: RefreshMode.LIVE_STREAM
val createdAt = node.get("createdAt")?.asLong() ?: 0L
val source = node.get("source")?.let { deserializeSource(it) } ?: return null
return FeedDefinition(
id = id,
name = name,
emoji = emoji,
pinned = pinned,
pinOrder = pinOrder,
source = source,
refreshMode = refreshMode,
createdAt = createdAt,
)
}
private fun serializeSource(source: FeedSource): ObjectNode =
mapper.createObjectNode().apply {
when (source) {
is FeedSource.Filter -> {
put("type", "filter")
set<ArrayNode>("hashtags", mapper.valueToTree(source.hashtags.toList()))
set<ArrayNode>("authors", mapper.valueToTree(source.authors.toList()))
set<ArrayNode>("relays", mapper.valueToTree(source.relays.toList()))
set<ArrayNode>("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
set<ArrayNode>("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
set<ArrayNode>("kinds", mapper.valueToTree(source.kinds.toList()))
}
is FeedSource.PeopleList -> {
put("type", "people_list")
put("kind", source.kind)
put("pubkey", source.pubkey)
put("dTag", source.dTag)
}
is FeedSource.InterestSet -> {
put("type", "interest_set")
put("kind", source.kind)
put("pubkey", source.pubkey)
put("dTag", source.dTag)
}
is FeedSource.DVM -> {
put("type", "dvm")
put("kind", source.kind)
put("pubkey", source.pubkey)
put("dTag", source.dTag)
}
is FeedSource.SingleRelay -> {
put("type", "single_relay")
put("url", source.url)
}
FeedSource.Global -> {
put("type", "global")
}
FeedSource.Following -> {
put("type", "following")
}
}
}
private fun deserializeSource(node: JsonNode): FeedSource? {
val type = node.get("type")?.asText() ?: return null
return when (type) {
"filter" -> {
FeedSource.Filter(
hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null,
authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null,
relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null,
kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null,
)
}
"people_list" -> {
FeedSource.PeopleList(
kind = node.get("kind")?.asInt() ?: 30000,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
)
}
"interest_set" -> {
FeedSource.InterestSet(
kind = node.get("kind")?.asInt() ?: 30015,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
)
}
"dvm" -> {
FeedSource.DVM(
kind = node.get("kind")?.asInt() ?: 31990,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
)
}
"single_relay" -> {
FeedSource.SingleRelay(
url = node.get("url")?.asText() ?: return null,
)
}
"global" -> {
FeedSource.Global
}
"following" -> {
FeedSource.Following
}
else -> {
null
}
}
}
}
@@ -0,0 +1,40 @@
/*
* 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.feeds.custom
import com.vitorpamplona.amethyst.commons.search.SearchQuery
fun SearchQuery.toFeedDefinition(
name: String,
emoji: String = "",
): FeedDefinition =
feedDefinition {
this.name = name
this.emoji = emoji
filter {
hashtags += this@toFeedDefinition.hashtags
authors += this@toFeedDefinition.authors
excludeKeywords += this@toFeedDefinition.excludeTerms
kinds += this@toFeedDefinition.kinds
}
}
fun SearchQuery.canBecomeFeed(): Boolean = hashtags.isNotEmpty() || authors.isNotEmpty() || kinds.isNotEmpty()
@@ -0,0 +1,224 @@
/*
* 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.feeds.custom
import kotlinx.collections.immutable.persistentListOf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FeedDefinitionSerializerTest {
@Test
fun roundTripFilterSource() {
val feed =
FeedDefinition(
id = "test-1",
name = "Bitcoin",
emoji = "\u20BF",
pinned = true,
pinOrder = 0,
source =
FeedSource.Filter(
hashtags = persistentListOf("bitcoin", "btc"),
authors = persistentListOf("abc123"),
relays = persistentListOf("wss://relay.damus.io"),
excludeAuthors = persistentListOf("spammer"),
excludeKeywords = persistentListOf("scam"),
kinds = persistentListOf(1, 6),
),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 1000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(1, deserialized.size)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripGlobalSource() {
val feed =
FeedDefinition(
id = "test-global",
name = "Global",
emoji = "\uD83C\uDF10",
pinned = true,
pinOrder = 1,
source = FeedSource.Global,
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 2000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(1, deserialized.size)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripFollowingSource() {
val feed =
FeedDefinition(
id = "test-following",
name = "Following",
emoji = "\uD83C\uDFE0",
pinned = false,
pinOrder = Int.MAX_VALUE,
source = FeedSource.Following,
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 3000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripDvmSource() {
val feed =
FeedDefinition(
id = "test-dvm",
name = "Trending",
emoji = "\uD83D\uDD25",
pinned = true,
pinOrder = 2,
source = FeedSource.DVM(kind = 31990, pubkey = "dvmpub123", dTag = "trending"),
refreshMode = RefreshMode.POLL_5MIN,
createdAt = 4000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripPeopleListSource() {
val feed =
FeedDefinition(
id = "test-people",
name = "Dev Friends",
emoji = "\uD83D\uDC65",
pinned = false,
pinOrder = Int.MAX_VALUE,
source = FeedSource.PeopleList(kind = 30000, pubkey = "mypub", dTag = "devs"),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 5000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripInterestSetSource() {
val feed =
FeedDefinition(
id = "test-interest",
name = "Nostr Dev",
emoji = "\uD83D\uDEE0",
pinned = false,
pinOrder = Int.MAX_VALUE,
source = FeedSource.InterestSet(kind = 30015, pubkey = "mypub", dTag = "nostrdev"),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 6000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun roundTripSingleRelaySource() {
val feed =
FeedDefinition(
id = "test-relay",
name = "Damus Relay",
emoji = "\uD83D\uDCE1",
pinned = false,
pinOrder = Int.MAX_VALUE,
source = FeedSource.SingleRelay(url = "wss://relay.damus.io"),
refreshMode = RefreshMode.LIVE_STREAM,
createdAt = 7000L,
)
val json = FeedDefinitionSerializer.serializeList(listOf(feed))
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(feed, deserialized[0])
}
@Test
fun multipleFeeds() {
val feeds =
listOf(
feedDefinition {
name = "Bitcoin"
emoji = "\u20BF"
filter {
hashtags += "bitcoin"
kinds += 1
}
},
feedDefinition {
name = "Lightning"
emoji = "\u26A1"
filter {
hashtags += "lightning"
hashtags += "ln"
}
},
)
val json = FeedDefinitionSerializer.serializeList(feeds)
val deserialized = FeedDefinitionSerializer.deserializeList(json)
assertEquals(2, deserialized.size)
assertEquals("Bitcoin", deserialized[0].name)
assertEquals("Lightning", deserialized[1].name)
}
@Test
fun emptyJsonReturnsEmptyList() {
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(""))
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" "))
}
@Test
fun defaultFeedsAreValid() {
val defaults = defaultFeeds()
assertEquals(2, defaults.size)
assertTrue(defaults[0].pinned)
assertTrue(defaults[1].pinned)
assertEquals(FeedSource.Following, defaults[0].source)
assertEquals(FeedSource.Global, defaults[1].source)
}
}
@@ -0,0 +1,103 @@
/*
* 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.feeds.custom
import com.vitorpamplona.amethyst.commons.search.SearchQuery
import kotlinx.collections.immutable.persistentListOf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SearchQueryToFeedTest {
@Test
fun convertsHashtagsToFeedFilter() {
val query =
SearchQuery(
hashtags = persistentListOf("bitcoin", "nostr"),
)
val feed = query.toFeedDefinition(name = "BTC + Nostr", emoji = "\u20BF")
assertEquals("BTC + Nostr", feed.name)
assertEquals("\u20BF", feed.emoji)
val source = feed.source as FeedSource.Filter
assertEquals(listOf("bitcoin", "nostr"), source.hashtags)
}
@Test
fun convertsAuthorsToFeedFilter() {
val query =
SearchQuery(
authors = persistentListOf("abc123", "def456"),
)
val feed = query.toFeedDefinition(name = "Devs")
val source = feed.source as FeedSource.Filter
assertEquals(listOf("abc123", "def456"), source.authors)
}
@Test
fun convertsExcludeTerms() {
val query =
SearchQuery(
hashtags = persistentListOf("bitcoin"),
excludeTerms = persistentListOf("scam", "spam"),
)
val feed = query.toFeedDefinition(name = "Clean BTC")
val source = feed.source as FeedSource.Filter
assertEquals(listOf("scam", "spam"), source.excludeKeywords)
}
@Test
fun convertsKinds() {
val query =
SearchQuery(
kinds = persistentListOf(1, 30023),
hashtags = persistentListOf("dev"),
)
val feed = query.toFeedDefinition(name = "Dev Articles")
val source = feed.source as FeedSource.Filter
assertEquals(listOf(1, 30023), source.kinds)
}
@Test
fun canBecomeFeedWithHashtags() {
assertTrue(SearchQuery(hashtags = persistentListOf("btc")).canBecomeFeed())
}
@Test
fun canBecomeFeedWithAuthors() {
assertTrue(SearchQuery(authors = persistentListOf("abc")).canBecomeFeed())
}
@Test
fun canNotBecomeFeedEmpty() {
assertFalse(SearchQuery.EMPTY.canBecomeFeed())
}
@Test
fun canNotBecomeFeedTextOnly() {
assertFalse(SearchQuery(text = "hello").canBecomeFeed())
}
}