Files
amethyst/commons/plans/2026-05-04-custom-feeds-testing-sheet.md
T
nrobi144 4010a57e68 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>
2026-05-06 09:59:30 +03:00

171 lines
7.0 KiB
Markdown

# 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 |