4010a57e68
- 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>
7.0 KiB
7.0 KiB
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)
./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
# 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:
- In
Main.ktApp()function (around line 710), create:
val feedRepository = remember { FeedDefinitionRepository(appScope) }
LaunchedEffect(Unit) { feedRepository.load(defaultFeeds()) }
- In the
CompositionLocalProviderblock (around line 1166), add:
LocalFeedRepository provides feedRepository,
LocalFeedScope provides appScope,
- This enables:
FeedsDrawerTabto read/write feedsCustomFeedScreento 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 |